@koda-sl/baker-cli 0.286.0-dev.93be96120 → 0.290.1

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/dist/cli.js CHANGED
@@ -58,7 +58,7 @@ import {
58
58
  ulid,
59
59
  validateCanvasDeep,
60
60
  ytDlpBlockSignal
61
- } from "./chunk-LN5O53BG.js";
61
+ } from "./chunk-V7BIRLPU.js";
62
62
  import {
63
63
  csvOrJson,
64
64
  daysAgoIso,
@@ -10509,14 +10509,14 @@ function collectionLine(collection) {
10509
10509
  }
10510
10510
  function accountsInTree(nodes) {
10511
10511
  const accounts = /* @__PURE__ */ new Set();
10512
- const walk = (node) => {
10512
+ const walk2 = (node) => {
10513
10513
  accounts.add(node.customerId);
10514
10514
  for (const child of node.children) {
10515
- walk(child);
10515
+ walk2(child);
10516
10516
  }
10517
10517
  };
10518
10518
  for (const node of nodes) {
10519
- walk(node);
10519
+ walk2(node);
10520
10520
  }
10521
10521
  return accounts;
10522
10522
  }
@@ -10683,11 +10683,11 @@ function parseKeywordEntry(raw, defaultMatch) {
10683
10683
  function rawTextEntries(value, rawArgs) {
10684
10684
  return repeatedValues(rawArgs, "text", value).flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean);
10685
10685
  }
10686
- function rawFileEntries(path42) {
10687
- if (typeof path42 !== "string" || path42.length === 0) {
10686
+ function rawFileEntries(path44) {
10687
+ if (typeof path44 !== "string" || path44.length === 0) {
10688
10688
  return [];
10689
10689
  }
10690
- return readFileSync2(path42, "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
10690
+ return readFileSync2(path44, "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
10691
10691
  }
10692
10692
  function keywordEntries(args, rawArgs) {
10693
10693
  const defaultMatch = typeof args["match-type"] === "string" ? args["match-type"].toUpperCase() : void 0;
@@ -10710,14 +10710,14 @@ function keywordEntries(args, rawArgs) {
10710
10710
  }
10711
10711
  return entries;
10712
10712
  }
10713
- function loadJsonFileArg(path42) {
10714
- if (typeof path42 !== "string" || path42.length === 0) {
10713
+ function loadJsonFileArg(path44) {
10714
+ if (typeof path44 !== "string" || path44.length === 0) {
10715
10715
  return {};
10716
10716
  }
10717
- const inline = path42.trimStart().startsWith("{");
10718
- const source = inline ? "inline JSON" : path42;
10717
+ const inline = path44.trimStart().startsWith("{");
10718
+ const source = inline ? "inline JSON" : path44;
10719
10719
  try {
10720
- const parsed = JSON.parse(inline ? path42 : readFileSync2(path42, "utf8"));
10720
+ const parsed = JSON.parse(inline ? path44 : readFileSync2(path44, "utf8"));
10721
10721
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
10722
10722
  failWriteValidation(`${source} must contain a JSON object`);
10723
10723
  }
@@ -10854,10 +10854,10 @@ async function stageUpdate(kind, customerId, target, payload, hints2) {
10854
10854
  async function stageTarget(kind, customerId, target, hints2) {
10855
10855
  await stageGoogleOp({ kind, customerId, target }, hints2);
10856
10856
  }
10857
- async function draftAction(path42, body, chat) {
10857
+ async function draftAction(path44, body, chat) {
10858
10858
  try {
10859
10859
  const chatId = resolveChatId(chat);
10860
- const response = await apiPost(path42, { chatId, ...body });
10860
+ const response = await apiPost(path44, { chatId, ...body });
10861
10861
  writeJsonEnvelope(response);
10862
10862
  } catch (err) {
10863
10863
  handleGoogleError(err);
@@ -12799,6 +12799,25 @@ var CAPTURED_WITHOUT_ROLE = [
12799
12799
  "targetid"
12800
12800
  ].sort();
12801
12801
 
12802
+ // ../api/src/analytics/conversionNames.ts
12803
+ function normalizeConversionName(name) {
12804
+ return name.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().split(" ").filter((word) => word !== "").map((word) => word.length > 3 && word.endsWith("s") ? word.slice(0, -1) : word).join(" ");
12805
+ }
12806
+ function findNameCollision(name, existing) {
12807
+ const normalized = normalizeConversionName(name);
12808
+ return existing.find((candidate) => candidate !== name && normalizeConversionName(candidate) === normalized) ?? null;
12809
+ }
12810
+ var STANDARD_CONVERSION_NAMES = [
12811
+ { name: "Lead", meaning: "Somebody handed over contact details" },
12812
+ { name: "Booked a call", meaning: "A meeting or demo was scheduled" },
12813
+ { name: "Quote requested", meaning: "Somebody asked what it would cost" },
12814
+ { name: "Purchase", meaning: "Money changed hands" },
12815
+ { name: "Signed up", meaning: "An account or trial was created" },
12816
+ { name: "Subscribed", meaning: "Somebody opted in to hear from them again" },
12817
+ { name: "Downloaded", meaning: "A guide, price list or asset was taken" },
12818
+ { name: "Contacted us", meaning: "A call, WhatsApp or email started from the page" }
12819
+ ];
12820
+
12802
12821
  // ../api/src/analytics/conversions.ts
12803
12822
  var CONVERSION_COUNT_MODES = ["once_per_visit", "every_time"];
12804
12823
  var PRIMARY_CONVERSION_TRIGGER = {
@@ -12820,6 +12839,9 @@ var MAX_CONVERSION_NAME = 80;
12820
12839
 
12821
12840
  // ../api/src/analytics/eventKey.ts
12822
12841
  var SEP = ":";
12842
+ function formEventKey(flowSlug, nodeId, triggerId) {
12843
+ return ["form", flowSlug, nodeId, triggerId].join(SEP);
12844
+ }
12823
12845
  function parseEventKey(key) {
12824
12846
  const cut = key.indexOf(SEP);
12825
12847
  if (cut <= 0) return null;
@@ -14363,6 +14385,19 @@ var analyticsTimelineRowSchema = z28.object({
14363
14385
  * what made the server-sent half of the feed look unattached to the rest.
14364
14386
  */
14365
14387
  knownBy: z28.string().default(""),
14388
+ /**
14389
+ * What this row counts as under the company's own conversion definitions, or
14390
+ * empty when it counts as nothing.
14391
+ *
14392
+ * The store's own key, projected rather than rebuilt here, so the feed and
14393
+ * the reports cannot disagree about what one event was. Two rows carrying the
14394
+ * same non-empty key are **one** conversion — the second is stored, listed,
14395
+ * and marked as already counted rather than hidden, because a sender who
14396
+ * posted a deal twice needs to see that the second request arrived.
14397
+ *
14398
+ * Only `event_stream` fills it; empty everywhere else.
14399
+ */
14400
+ conversionKey: z28.string().default(""),
14366
14401
  /**
14367
14402
  * `email` | `phone` — which of the two they gave.
14368
14403
  *
@@ -14403,6 +14438,17 @@ var analyticsTimelineRowSchema = z28.object({
14403
14438
  runId: z28.string(),
14404
14439
  engagementMs: z28.number().int().nonnegative(),
14405
14440
  scrollDepth: z28.number().int().nonnegative(),
14441
+ /**
14442
+ * The A/B test this row was stamped into, and which side the edge served —
14443
+ * `a` for the page as it is, `b` for the new variant.
14444
+ *
14445
+ * Set only on rows a page under test produced, which is how a person's story
14446
+ * can say which version they were shown. Empty on a server-sent row: a CRM
14447
+ * knows nothing about the split, and the conversion it posts is attributed
14448
+ * to a side by WHO did it (see `experiment_conversions`), never by a stamp.
14449
+ */
14450
+ experimentId: z28.string().default(""),
14451
+ variant: z28.string().default(""),
14406
14452
  /** `success` | `failed` | `""` for a dispatch that has not settled yet. */
14407
14453
  outcome: z28.string(),
14408
14454
  /** What the destination said when it refused. Empty on success. */
@@ -15227,17 +15273,17 @@ function ignoredColumnHints(columns) {
15227
15273
  `Ignored ${columns.length} column(s) that are not part of a conversion: ${columns.join(", ")}. A conversion carries an order id, a date, and something to match on: a click id (gclid / gbraid / wbraid), an email, a phone, or all four of givenName + familyName + country + postalCode. Optionally a value + currency, conversionCount, and eventSource (WEB / APP / IN_STORE / PHONE / OTHER). A column named just \`Name\` is ignored on purpose \u2014 Google needs the first and last name separately, so split it before re-running.`
15228
15274
  ];
15229
15275
  }
15230
- function readRowsFile(path42) {
15231
- const inline = path42.trimStart().startsWith("[") || path42.trimStart().startsWith("{");
15276
+ function readRowsFile(path44) {
15277
+ const inline = path44.trimStart().startsWith("[") || path44.trimStart().startsWith("{");
15232
15278
  let text2;
15233
15279
  try {
15234
- text2 = inline ? path42 : readFileSync4(path42, "utf8");
15280
+ text2 = inline ? path44 : readFileSync4(path44, "utf8");
15235
15281
  } catch (err) {
15236
- failWriteValidation(`could not read ${path42}: ${err instanceof Error ? err.message : String(err)}`);
15282
+ failWriteValidation(`could not read ${path44}: ${err instanceof Error ? err.message : String(err)}`);
15237
15283
  }
15238
15284
  const parsed = parseConversionRowsText(text2);
15239
15285
  if (!parsed.ok) {
15240
- failWriteValidation(`${inline ? "the conversions passed inline" : path42}: ${parsed.error}`);
15286
+ failWriteValidation(`${inline ? "the conversions passed inline" : path44}: ${parsed.error}`);
15241
15287
  }
15242
15288
  return { rows: parsed.rows, ignoredColumns: parsed.ignoredColumns };
15243
15289
  }
@@ -17076,19 +17122,19 @@ function failWriteValidation2(message) {
17076
17122
  writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message } });
17077
17123
  process.exit(1);
17078
17124
  }
17079
- function loadJsonFileArg2(path42) {
17080
- if (typeof path42 !== "string" || path42.length === 0) {
17125
+ function loadJsonFileArg2(path44) {
17126
+ if (typeof path44 !== "string" || path44.length === 0) {
17081
17127
  return {};
17082
17128
  }
17083
17129
  try {
17084
- const parsed = JSON.parse(readFileSync5(path42, "utf8"));
17130
+ const parsed = JSON.parse(readFileSync5(path44, "utf8"));
17085
17131
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
17086
- failWriteValidation2(`${path42} must contain a JSON object`);
17132
+ failWriteValidation2(`${path44} must contain a JSON object`);
17087
17133
  }
17088
17134
  return parsed;
17089
17135
  } catch (err) {
17090
17136
  if (err instanceof SyntaxError) {
17091
- failWriteValidation2(`${path42} is not valid JSON: ${err.message}`);
17137
+ failWriteValidation2(`${path44} is not valid JSON: ${err.message}`);
17092
17138
  }
17093
17139
  throw err;
17094
17140
  }
@@ -17173,15 +17219,15 @@ function parseLocaleFlag(value) {
17173
17219
  }
17174
17220
  return { language: match[1], country: match[2].toUpperCase() };
17175
17221
  }
17176
- function loadTargetingFileArg(path42) {
17177
- if (typeof path42 !== "string" || path42.length === 0) {
17222
+ function loadTargetingFileArg(path44) {
17223
+ if (typeof path44 !== "string" || path44.length === 0) {
17178
17224
  return void 0;
17179
17225
  }
17180
- const parsed = loadJsonFileArg2(path42);
17226
+ const parsed = loadJsonFileArg2(path44);
17181
17227
  const criteria = parsed.targetingCriteria ?? parsed;
17182
17228
  if (!criteria.include) {
17183
17229
  failWriteValidation2(
17184
- `${path42} must contain targeting criteria with an "include" block (see baker schema ads.linkedin.campaigns.create)`
17230
+ `${path44} must contain targeting criteria with an "include" block (see baker schema ads.linkedin.campaigns.create)`
17185
17231
  );
17186
17232
  }
17187
17233
  return criteria;
@@ -17216,14 +17262,14 @@ function parseCsvLine(line) {
17216
17262
  cells.push(current);
17217
17263
  return cells.map((cell3) => cell3.trim());
17218
17264
  }
17219
- function parseListFileArg(path42, maxRows) {
17220
- if (typeof path42 !== "string" || path42.length === 0) {
17265
+ function parseListFileArg(path44, maxRows) {
17266
+ if (typeof path44 !== "string" || path44.length === 0) {
17221
17267
  return void 0;
17222
17268
  }
17223
- const raw = readFileSync5(path42, "utf8");
17269
+ const raw = readFileSync5(path44, "utf8");
17224
17270
  const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
17225
17271
  if (lines.length < 2) {
17226
- failWriteValidation2(`${path42} needs a header row and at least one data row`);
17272
+ failWriteValidation2(`${path44} needs a header row and at least one data row`);
17227
17273
  }
17228
17274
  const columns = parseCsvLine(lines[0]).map((column) => column.trim());
17229
17275
  const rows = [];
@@ -17242,7 +17288,7 @@ function parseListFileArg(path42, maxRows) {
17242
17288
  }
17243
17289
  }
17244
17290
  if (rows.length > maxRows) {
17245
- failWriteValidation2(`${path42} has ${rows.length} rows \u2014 the inline limit is ${maxRows}. Split the list.`);
17291
+ failWriteValidation2(`${path44} has ${rows.length} rows \u2014 the inline limit is ${maxRows}. Split the list.`);
17246
17292
  }
17247
17293
  return { columns, rows };
17248
17294
  }
@@ -17338,11 +17384,11 @@ function readPositionals(args) {
17338
17384
  function splitIdList(raw) {
17339
17385
  return raw.split(",").map((id) => id.trim()).filter(Boolean);
17340
17386
  }
17341
- function idsFileEntries(path42) {
17342
- if (typeof path42 !== "string" || path42.length === 0) {
17387
+ function idsFileEntries(path44) {
17388
+ if (typeof path44 !== "string" || path44.length === 0) {
17343
17389
  return [];
17344
17390
  }
17345
- return readFileSync5(path42, "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).flatMap(splitIdList);
17391
+ return readFileSync5(path44, "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).flatMap(splitIdList);
17346
17392
  }
17347
17393
  function requireTargets(args, entity) {
17348
17394
  const positionals = readPositionals(args);
@@ -20062,9 +20108,9 @@ function compactRow(row) {
20062
20108
  ...destination.postUrn ? { postUrn: destination.postUrn } : {}
20063
20109
  };
20064
20110
  }
20065
- function readPath(row, path42) {
20111
+ function readPath(row, path44) {
20066
20112
  let current = row;
20067
- for (const segment of path42.split(".")) {
20113
+ for (const segment of path44.split(".")) {
20068
20114
  const record = asRecord2(current);
20069
20115
  if (!record) return void 0;
20070
20116
  current = record[segment];
@@ -20074,10 +20120,10 @@ function readPath(row, path42) {
20074
20120
  function projectFields(rows, paths) {
20075
20121
  return rows.map((row) => {
20076
20122
  const projected = {};
20077
- for (const path42 of paths) {
20078
- const value = readPath(row, path42);
20123
+ for (const path44 of paths) {
20124
+ const value = readPath(row, path44);
20079
20125
  if (value !== void 0) {
20080
- projected[path42] = value;
20126
+ projected[path44] = value;
20081
20127
  }
20082
20128
  }
20083
20129
  return projected;
@@ -20332,14 +20378,14 @@ function collectionLine2(collection) {
20332
20378
  }
20333
20379
  function accountsInTree2(nodes) {
20334
20380
  const accounts = /* @__PURE__ */ new Set();
20335
- const walk = (node) => {
20381
+ const walk2 = (node) => {
20336
20382
  accounts.add(node.accountId);
20337
20383
  for (const child of node.children) {
20338
- walk(child);
20384
+ walk2(child);
20339
20385
  }
20340
20386
  };
20341
20387
  for (const node of nodes) {
20342
- walk(node);
20388
+ walk2(node);
20343
20389
  }
20344
20390
  return accounts;
20345
20391
  }
@@ -21673,11 +21719,11 @@ var updateStatusSchema = z29.enum(UPDATE_STATUSES);
21673
21719
  function currencyMinimums2(currencyCode) {
21674
21720
  return CURRENCY_MINIMUMS2[currencyCode] ?? DEFAULT_CURRENCY_MINIMUM2;
21675
21721
  }
21676
- function validateDailyBudgetFloor(money, ctx, path42) {
21722
+ function validateDailyBudgetFloor(money, ctx, path44) {
21677
21723
  if (money?.currencyCode) {
21678
21724
  const min = currencyMinimums2(money.currencyCode).dailyBudgetMin;
21679
21725
  if (Number(money.amount) < min) {
21680
- ctx.addIssue({ code: "custom", path: path42, message: `below the ${min} ${money.currencyCode} daily minimum` });
21726
+ ctx.addIssue({ code: "custom", path: path44, message: `below the ${min} ${money.currencyCode} daily minimum` });
21681
21727
  }
21682
21728
  }
21683
21729
  }
@@ -22437,19 +22483,19 @@ function failWriteValidation3(message) {
22437
22483
  writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message } });
22438
22484
  process.exit(1);
22439
22485
  }
22440
- function loadJsonFileArg3(path42) {
22441
- if (typeof path42 !== "string" || path42.length === 0) {
22486
+ function loadJsonFileArg3(path44) {
22487
+ if (typeof path44 !== "string" || path44.length === 0) {
22442
22488
  return {};
22443
22489
  }
22444
22490
  try {
22445
- const parsed = JSON.parse(readFileSync9(path42, "utf8"));
22491
+ const parsed = JSON.parse(readFileSync9(path44, "utf8"));
22446
22492
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
22447
- failWriteValidation3(`${path42} must contain a JSON object`);
22493
+ failWriteValidation3(`${path44} must contain a JSON object`);
22448
22494
  }
22449
22495
  return parsed;
22450
22496
  } catch (err) {
22451
22497
  if (err instanceof SyntaxError) {
22452
- failWriteValidation3(`${path42} is not valid JSON: ${err.message}`);
22498
+ failWriteValidation3(`${path44} is not valid JSON: ${err.message}`);
22453
22499
  }
22454
22500
  throw err;
22455
22501
  }
@@ -25362,14 +25408,14 @@ function collectionLine3(collection) {
25362
25408
  }
25363
25409
  function accountsInTree3(nodes) {
25364
25410
  const accounts = /* @__PURE__ */ new Set();
25365
- const walk = (node) => {
25411
+ const walk2 = (node) => {
25366
25412
  accounts.add(node.accountId);
25367
25413
  for (const child of node.children) {
25368
- walk(child);
25414
+ walk2(child);
25369
25415
  }
25370
25416
  };
25371
25417
  for (const node of nodes) {
25372
- walk(node);
25418
+ walk2(node);
25373
25419
  }
25374
25420
  return accounts;
25375
25421
  }
@@ -27184,6 +27230,9 @@ Full guides: __tooling__/docs/tools/baker/ads-<platform>.md (google|meta|linkedi
27184
27230
  }
27185
27231
  });
27186
27232
 
27233
+ // src/commands/analytics/index.ts
27234
+ import { readFileSync as readFileSync12 } from "fs";
27235
+
27187
27236
  // ../api/src/event-ingest/arrivals.ts
27188
27237
  import { z as z31 } from "zod";
27189
27238
 
@@ -27997,50 +28046,325 @@ var ingestEnvelopeSchema = z32.object({ ok: z32.literal(true), data: ingestRespo
27997
28046
  // src/commands/analytics/index.ts
27998
28047
  import { defineCommand as defineCommand95 } from "citty";
27999
28048
 
28049
+ // src/commands/flows/shared.ts
28050
+ import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync11, writeFileSync as writeFileSync3 } from "fs";
28051
+ import { join as join2 } from "path";
28052
+ var FLOWS_DIR = "src/lib/flow-engine/flows";
28053
+ function flowsDir() {
28054
+ let dir = process.cwd();
28055
+ for (let i = 0; i < 6; i++) {
28056
+ const candidate = join2(dir, FLOWS_DIR);
28057
+ if (existsSync3(candidate)) {
28058
+ return candidate;
28059
+ }
28060
+ const parent = join2(dir, "..");
28061
+ if (parent === dir) {
28062
+ break;
28063
+ }
28064
+ dir = parent;
28065
+ }
28066
+ return join2(process.cwd(), FLOWS_DIR);
28067
+ }
28068
+ function failLocal(message) {
28069
+ writeJson({ ok: false, error: { code: "NOT_FOUND", message } });
28070
+ process.exit(1);
28071
+ }
28072
+ function listFlowSlugs() {
28073
+ const dir = flowsDir();
28074
+ if (!existsSync3(dir)) {
28075
+ return [];
28076
+ }
28077
+ return readdirSync2(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith("_") && entry.name !== ".gitkeep").map((entry) => entry.name).sort();
28078
+ }
28079
+ function flowDataPath(slug) {
28080
+ return join2(flowsDir(), slug, "_data.json");
28081
+ }
28082
+ function writeFlowTree(slug, tree) {
28083
+ writeFileSync3(flowDataPath(slug), `${JSON.stringify(tree, null, 2)}
28084
+ `, "utf-8");
28085
+ }
28086
+ function walkNodes(node, acc = []) {
28087
+ if (!node || typeof node !== "object") return acc;
28088
+ acc.push(node);
28089
+ if (Array.isArray(node.children)) {
28090
+ for (const child of node.children) walkNodes(child, acc);
28091
+ }
28092
+ return acc;
28093
+ }
28094
+ function collectSideEffects(tree) {
28095
+ return walkNodes(tree).flatMap(
28096
+ (node) => Array.isArray(node.sideEffects) ? node.sideEffects.filter((sideEffect) => Boolean(sideEffect) && typeof sideEffect === "object").map((sideEffect) => ({ node, sideEffect })) : []
28097
+ );
28098
+ }
28099
+ function flowExists(slug) {
28100
+ return existsSync3(join2(flowsDir(), slug, "_data.json"));
28101
+ }
28102
+ function readFlowTree(slug) {
28103
+ const path44 = join2(flowsDir(), slug, "_data.json");
28104
+ if (!existsSync3(path44)) {
28105
+ failLocal(`No form "${slug}". Run "baker flows list" to see the forms in this workspace.`);
28106
+ }
28107
+ try {
28108
+ return JSON.parse(readFileSync11(path44, "utf-8"));
28109
+ } catch (error) {
28110
+ failLocal(`Could not read form "${slug}": ${error instanceof Error ? error.message : String(error)}`);
28111
+ }
28112
+ }
28113
+ function isConfigured(value) {
28114
+ return typeof value === "string" && value.length > 0;
28115
+ }
28116
+ function nodeMeta(record) {
28117
+ const nodeId = typeof record.id === "string" ? record.id : "";
28118
+ const nodeName = typeof record.name === "string" ? record.name : void 0;
28119
+ return { nodeId, ...nodeName ? { nodeName } : {} };
28120
+ }
28121
+ function widgetNodeStatus(record, meta) {
28122
+ const nodeData = record.nodeData;
28123
+ const nodeType = nodeData?.type;
28124
+ if (typeof nodeType !== "string" || !FLOW_RESOURCE_NODE_TYPES.includes(nodeType)) {
28125
+ return null;
28126
+ }
28127
+ const external = nodeData?.form?.external;
28128
+ const selected = external != null && typeof external === "object";
28129
+ const name = selected ? external.name : void 0;
28130
+ return {
28131
+ ...meta,
28132
+ target: "node",
28133
+ kind: nodeType,
28134
+ status: selected ? `${nodeType} \u2014 selected${typeof name === "string" ? ` "${name}"` : ""}` : `${nodeType} \u2014 not selected`,
28135
+ needsInput: !selected
28136
+ };
28137
+ }
28138
+ function sideEffectNeedsInput(raw) {
28139
+ const effect = raw;
28140
+ const type = effect?.type;
28141
+ if (typeof type !== "string" || !FLOW_SECRET_SIDE_EFFECT_TYPES.includes(type)) {
28142
+ return false;
28143
+ }
28144
+ const configured = isConfigured(effect?.encryptedConfig);
28145
+ if (!isFlowOauthSideEffect(type)) return !configured;
28146
+ return !isConfigured(effect?.oauthProviderId) || !configured;
28147
+ }
28148
+ function sideEffectStatus(raw, meta) {
28149
+ const effect = raw;
28150
+ const type = effect?.type;
28151
+ if (typeof type !== "string" || !FLOW_SECRET_SIDE_EFFECT_TYPES.includes(type)) {
28152
+ return null;
28153
+ }
28154
+ const configured = isConfigured(effect?.encryptedConfig);
28155
+ const oauth = isFlowOauthSideEffect(type);
28156
+ const connected = isConfigured(effect?.oauthProviderId);
28157
+ const needs = FLOW_SECRET_FIELDS[type];
28158
+ const status = oauth ? `${type} \u2014 connection ${connected ? "[connected]" : "[needs connection]"}, config ${configured ? "[set]" : "[missing]"}` : configured ? `${type} \u2014 [configured]` : `${type} \u2014 [missing]${needs.length > 0 ? ` (needs: ${needs.join(", ")})` : ""}`;
28159
+ return {
28160
+ ...meta,
28161
+ target: "sideEffect",
28162
+ ...typeof effect?.id === "string" ? { sideEffectId: effect.id } : {},
28163
+ kind: type,
28164
+ status,
28165
+ needsInput: sideEffectNeedsInput(effect)
28166
+ };
28167
+ }
28168
+ function requestFlowInputCall(slug, status) {
28169
+ const args = status.target === "node" ? `{ target: "node", flowSlug: "${slug}", nodeId: "${status.nodeId}", nodeType: "${status.kind}" }` : `{ target: "sideEffect", flowSlug: "${slug}", nodeId: "${status.nodeId}", sideEffectId: "${status.sideEffectId}", sideEffectType: "${status.kind}" }`;
28170
+ return `request_flow_input with ${args}`;
28171
+ }
28172
+ function collectConfigStatus(node, acc) {
28173
+ if (!node || typeof node !== "object") {
28174
+ return;
28175
+ }
28176
+ const record = node;
28177
+ const meta = nodeMeta(record);
28178
+ const widget = widgetNodeStatus(record, meta);
28179
+ if (widget) {
28180
+ acc.push(widget);
28181
+ }
28182
+ if (Array.isArray(record.sideEffects)) {
28183
+ for (const raw of record.sideEffects) {
28184
+ const status = sideEffectStatus(raw, meta);
28185
+ if (status) {
28186
+ acc.push(status);
28187
+ }
28188
+ }
28189
+ }
28190
+ if (Array.isArray(record.children)) {
28191
+ for (const child of record.children) {
28192
+ collectConfigStatus(child, acc);
28193
+ }
28194
+ }
28195
+ }
28196
+ function redactTree(node) {
28197
+ if (Array.isArray(node)) {
28198
+ return node.map(redactTree);
28199
+ }
28200
+ if (!node || typeof node !== "object") {
28201
+ return node;
28202
+ }
28203
+ const out = {};
28204
+ for (const [key, value] of Object.entries(node)) {
28205
+ if (key === "encryptedConfig") {
28206
+ out[key] = isConfigured(value) ? "[configured]" : value;
28207
+ } else {
28208
+ out[key] = redactTree(value);
28209
+ }
28210
+ }
28211
+ return out;
28212
+ }
28213
+ function stepNameKey(name) {
28214
+ return name.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toUpperCase();
28215
+ }
28216
+ function isUsableStepNameKey(key) {
28217
+ return /^[\p{ID_Start}$_][\p{ID_Continue}$‌‍]*$/u.test(key);
28218
+ }
28219
+
28000
28220
  // src/commands/analytics/conversionArgs.ts
28001
28221
  var ConversionArgsError = class extends Error {
28002
28222
  };
28223
+ function allValues(flag, argv) {
28224
+ const values = [];
28225
+ for (let i = 0; i < argv.length; i += 1) {
28226
+ const arg = argv[i];
28227
+ if (arg === flag) {
28228
+ const next = argv[i + 1];
28229
+ if (next !== void 0 && !next.startsWith("--")) values.push(next);
28230
+ continue;
28231
+ }
28232
+ if (arg.startsWith(`${flag}=`)) values.push(arg.slice(flag.length + 1));
28233
+ }
28234
+ return values.map((value) => value.trim()).filter((value) => value !== "");
28235
+ }
28003
28236
  function onlyOnce(value, flag, argv) {
28004
28237
  if (value === void 0) return void 0;
28005
28238
  const given = argv.filter((arg) => arg === flag || arg.startsWith(`${flag}=`)).length;
28006
28239
  if (given > 1) {
28007
28240
  throw new ConversionArgsError(
28008
- `${flag} was given ${given} times and only the last would have been used. Count one outcome per call.`
28241
+ `${flag} was given ${given} times and only the last would have been used. It takes one value.`
28009
28242
  );
28010
28243
  }
28011
28244
  const trimmed = String(value).trim();
28012
28245
  return trimmed === "" ? void 0 : trimmed;
28013
28246
  }
28014
- function parseConversionsArgs(raw, argv) {
28015
- const eventKey = onlyOnce(raw.event, "--event", argv);
28016
- const name = onlyOnce(raw.name, "--name", argv);
28017
- const countMode = onlyOnce(raw["count-mode"], "--count-mode", argv);
28018
- const remove = onlyOnce(raw.remove, "--remove", argv);
28019
- if (eventKey !== void 0 && name === void 0) {
28020
- throw new ConversionArgsError("--event needs --name: a conversion nobody named is a row labelled with a UUID.");
28247
+ function repeatable(flag, cittyValue, argv) {
28248
+ const fromArgv = allValues(flag, argv);
28249
+ if (fromArgv.length > 0) return fromArgv;
28250
+ if (cittyValue === void 0) return [];
28251
+ const trimmed = String(cittyValue).trim();
28252
+ return trimmed === "" ? [] : [trimmed];
28253
+ }
28254
+ function checkFlagShape(args) {
28255
+ if (args.eventKeys.length > 0 && args.name === void 0 && args.countMode === void 0) {
28256
+ throw new ConversionArgsError(
28257
+ "--event needs --name: a conversion nobody named is a row labelled with a UUID. (--event with only --count-mode changes how an event already counted is counted.)"
28258
+ );
28021
28259
  }
28022
- if (name !== void 0 && eventKey === void 0 && remove === void 0) {
28260
+ if (args.name !== void 0 && args.eventKeys.length === 0 && args.renameTo === void 0) {
28023
28261
  throw new ConversionArgsError("--name needs --event, naming which event to count.");
28024
28262
  }
28025
- if (eventKey !== void 0 && parseEventKey(eventKey) === null) {
28263
+ if (args.renameFrom !== void 0 && args.renameTo === void 0) {
28264
+ throw new ConversionArgsError('--rename needs --to: `--rename "Lead form" --to "Lead"`.');
28265
+ }
28266
+ if (args.renameTo !== void 0 && args.renameFrom === void 0) {
28026
28267
  throw new ConversionArgsError(
28027
- `"${eventKey}" is not an event key. Run with --candidates and copy one; they look like form:<form>:<step>:<trigger>, submit:<form>, page:<name> or exit:<host>.`
28268
+ '--to needs --rename, naming which outcome to rename: `--rename "Lead form" --to "Lead"`.'
28028
28269
  );
28029
28270
  }
28271
+ }
28272
+ function checkEventKeys(eventKeys) {
28273
+ for (const eventKey of eventKeys) {
28274
+ if (parseEventKey(eventKey) === null) {
28275
+ throw new ConversionArgsError(
28276
+ `"${eventKey}" is not an event key. Run with --candidates (or --flow <slug> for one Form) and copy one; they look like form:<form>:<step>:<trigger>, submit:<form>, page:<name> or exit:<host>.`
28277
+ );
28278
+ }
28279
+ }
28280
+ }
28281
+ function readDays(raw) {
28282
+ if (raw === void 0) return void 0;
28283
+ const days = Number(raw);
28284
+ if (!Number.isFinite(days) || days <= 0) {
28285
+ throw new ConversionArgsError("--days must be a positive number of days.");
28286
+ }
28287
+ return days;
28288
+ }
28289
+ function parseConversionsArgs(raw, argv) {
28290
+ const countMode = onlyOnce(raw["count-mode"], "--count-mode", argv);
28030
28291
  if (countMode !== void 0 && !CONVERSION_COUNT_MODES.includes(countMode)) {
28031
28292
  throw new ConversionArgsError(`--count-mode must be one of ${CONVERSION_COUNT_MODES.join(", ")}.`);
28032
28293
  }
28033
- const days = raw.days === void 0 ? void 0 : Number(raw.days);
28034
- if (days !== void 0 && (!Number.isFinite(days) || days <= 0)) {
28035
- throw new ConversionArgsError("--days must be a positive number of days.");
28294
+ const args = {
28295
+ eventKeys: repeatable("--event", raw.event, argv),
28296
+ removeKeys: repeatable("--remove", raw.remove, argv),
28297
+ name: onlyOnce(raw.name, "--name", argv),
28298
+ countMode,
28299
+ renameFrom: onlyOnce(raw.rename, "--rename", argv),
28300
+ renameTo: onlyOnce(raw.to, "--to", argv),
28301
+ removeName: onlyOnce(raw["remove-name"], "--remove-name", argv),
28302
+ flowSlug: onlyOnce(raw.flow, "--flow", argv),
28303
+ candidates: raw.candidates === true,
28304
+ days: readDays(raw.days)
28305
+ };
28306
+ checkFlagShape(args);
28307
+ checkEventKeys(args.eventKeys);
28308
+ return args;
28309
+ }
28310
+ function existingNames(existing) {
28311
+ return [...new Set(existing.map((row) => row.name))];
28312
+ }
28313
+ function checkNameCollision(name, existing) {
28314
+ const collision = findNameCollision(name, existingNames(existing));
28315
+ if (collision === null) return;
28316
+ throw new ConversionArgsError(
28317
+ `This company already counts "${collision}", and "${name}" would be a second row for the same outcome \u2014 Baker groups by exact name, so the number would be split in two. Use "${collision}" to make them one number, or pick a name that means something different.`
28318
+ );
28319
+ }
28320
+ function under(name, existing) {
28321
+ const rows = existing.filter((row) => row.name === name);
28322
+ if (rows.length === 0) {
28323
+ throw new ConversionArgsError(
28324
+ `Nothing is counted under "${name}". This company counts: ${existingNames(existing).join(", ") || "nothing yet"}.`
28325
+ );
28036
28326
  }
28037
- return { eventKey, name, countMode, remove, candidates: raw.candidates === true, days };
28327
+ return rows;
28038
28328
  }
28039
- function conversionsWrite(args) {
28040
- const set = args.eventKey !== void 0 && args.name !== void 0 ? [{ eventKey: args.eventKey, name: args.name, ...args.countMode ? { countMode: args.countMode } : {} }] : void 0;
28041
- const remove = args.remove !== void 0 ? [args.remove] : void 0;
28042
- if (!set && !remove) return null;
28043
- return { ...set ? { set } : {}, ...remove ? { remove } : {} };
28329
+ function setFromEvents(args, existing) {
28330
+ if (args.eventKeys.length === 0) return [];
28331
+ if (args.name !== void 0) {
28332
+ checkNameCollision(args.name, existing);
28333
+ const name = args.name;
28334
+ return args.eventKeys.map((eventKey) => ({
28335
+ eventKey,
28336
+ name,
28337
+ ...args.countMode ? { countMode: args.countMode } : {}
28338
+ }));
28339
+ }
28340
+ return args.eventKeys.map((eventKey) => {
28341
+ const current = existing.find((row) => row.eventKey === eventKey);
28342
+ if (!current) {
28343
+ throw new ConversionArgsError(
28344
+ `"${eventKey}" is not counted, so there is no count mode to change. Add it with --event "${eventKey}" --name "<what a person calls it>" --count-mode ${args.countMode}.`
28345
+ );
28346
+ }
28347
+ return { eventKey, name: current.name, countMode: args.countMode };
28348
+ });
28349
+ }
28350
+ function setFromRename(args, existing) {
28351
+ if (args.renameFrom === void 0 || args.renameTo === void 0) return [];
28352
+ const renameTo = args.renameTo;
28353
+ checkNameCollision(renameTo, existing);
28354
+ return under(args.renameFrom, existing).map((row) => ({
28355
+ eventKey: row.eventKey,
28356
+ name: renameTo,
28357
+ countMode: row.countMode
28358
+ }));
28359
+ }
28360
+ function conversionsWrite(args, existing) {
28361
+ const set = [...setFromEvents(args, existing), ...setFromRename(args, existing)];
28362
+ const remove = [
28363
+ ...args.removeKeys,
28364
+ ...args.removeName === void 0 ? [] : under(args.removeName, existing).map((row) => row.eventKey)
28365
+ ];
28366
+ if (set.length === 0 && remove.length === 0) return null;
28367
+ return { ...set.length > 0 ? { set } : {}, ...remove.length > 0 ? { remove } : {} };
28044
28368
  }
28045
28369
 
28046
28370
  // src/commands/analytics/conversionHints.ts
@@ -28078,6 +28402,163 @@ function conversionHints(definitions, candidates, askedForCandidates) {
28078
28402
  }
28079
28403
  return hints2;
28080
28404
  }
28405
+ function namingHints(definitions) {
28406
+ const names = [...new Set(definitions.map((row) => row.name))];
28407
+ if (names.length === 0) {
28408
+ return [
28409
+ `Nothing is named yet, so the first name sets the vocabulary for this company. Start from the standard set \u2014 ${STANDARD_CONVERSION_NAMES.map((entry) => entry.name).join(", ")} \u2014 and stay in the company's own language once one exists.`
28410
+ ];
28411
+ }
28412
+ return [
28413
+ `Names in use here: ${names.join(", ")}. Reuse one EXACTLY when another Form produces the same outcome \u2014 that is what makes them one number \u2014 and use \`--rename "<old>" --to "<existing>"\` to merge two that should always have been one.`
28414
+ ];
28415
+ }
28416
+
28417
+ // src/commands/analytics/conversionList.ts
28418
+ function mergeStoredWithCounts(stored, reported) {
28419
+ const counts = new Map(reported.map((row) => [row.name, row]));
28420
+ const groups = /* @__PURE__ */ new Map();
28421
+ for (const row of stored) {
28422
+ const group = groups.get(row.name) ?? [];
28423
+ group.push(row);
28424
+ groups.set(row.name, group);
28425
+ }
28426
+ return [...groups.entries()].map(([name, rows]) => {
28427
+ const modes = new Set(rows.map((row) => row.countMode));
28428
+ const counted = counts.get(name);
28429
+ return {
28430
+ name,
28431
+ eventKeys: rows.map((row) => row.eventKey).sort(),
28432
+ // A real state and usually an editing accident, which is why it is said
28433
+ // out loud rather than resolved to one of them.
28434
+ countMode: modes.size === 1 ? [...modes][0] : "mixed",
28435
+ // Zero rather than absent: an outcome that is counted and has never
28436
+ // fired is the most actionable row on this report, and dropping it
28437
+ // would make it indistinguishable from one nobody has named.
28438
+ conversions: counted?.conversions ?? 0,
28439
+ convertingVisits: counted?.convertingVisits ?? 0,
28440
+ lastSeen: counted?.lastSeen ?? null
28441
+ };
28442
+ }).sort((a, b) => a.name.localeCompare(b.name));
28443
+ }
28444
+
28445
+ // src/commands/analytics/flowConversionKeys.ts
28446
+ function walk(node, acc = []) {
28447
+ if (!node || typeof node !== "object") return acc;
28448
+ const record = node;
28449
+ acc.push(record);
28450
+ if (Array.isArray(record.children)) for (const child of record.children) walk(child, acc);
28451
+ return acc;
28452
+ }
28453
+ function flowConversionCandidates(tree, slug) {
28454
+ const primary = [];
28455
+ const secondary = [];
28456
+ for (const node of walk(tree)) {
28457
+ const id = typeof node.id === "string" ? node.id : null;
28458
+ if (!id) continue;
28459
+ const triggers = flowNodeTriggers(node.nodeData);
28460
+ for (const triggerId of triggers.available) {
28461
+ const candidate = {
28462
+ eventKey: formEventKey(slug, id, triggerId),
28463
+ nodeId: id,
28464
+ nodeName: typeof node.name === "string" ? node.name : id,
28465
+ nodeType: triggers.type,
28466
+ triggerId,
28467
+ isCompletion: triggers.completion === triggerId
28468
+ };
28469
+ (candidate.isCompletion ? primary : secondary).push(candidate);
28470
+ }
28471
+ }
28472
+ return [
28473
+ ...primary,
28474
+ ...secondary,
28475
+ {
28476
+ eventKey: `submit:${slug}`,
28477
+ nodeId: null,
28478
+ nodeName: "the Form ran out of steps",
28479
+ nodeType: null,
28480
+ triggerId: null,
28481
+ // A Form reaching its last node is as primary as an outcome gets for a
28482
+ // Form with no widget in it — but it never fires on one that ends in a
28483
+ // booking widget or a redirect, so it does not lead the list.
28484
+ isCompletion: false
28485
+ }
28486
+ ];
28487
+ }
28488
+ function formKeyRefusal(eventKey, tree, slug) {
28489
+ const parsed = parseEventKey(eventKey);
28490
+ if (!parsed || parsed.family !== "form" || parsed.flowSlug !== slug) return null;
28491
+ const node = walk(tree).find((candidate) => candidate.id === parsed.nodeId);
28492
+ if (!node) {
28493
+ return {
28494
+ message: `"${slug}" has no step with id "${parsed.nodeId}", so this key would be stored and count zero forever.`,
28495
+ fix: `Steps in this Form: ${walk(tree).map((n) => `${String(n.id)} (${String(n.name ?? "unnamed")})`).join(", ")}. \`baker analytics conversions --flow ${slug}\` prints each one already spelled as an event key.`
28496
+ };
28497
+ }
28498
+ const triggers = flowNodeTriggers(node.nodeData);
28499
+ if (!triggers.known || triggers.available.includes(parsed.triggerId)) return null;
28500
+ return {
28501
+ message: `Step "${String(node.name ?? node.id)}" never raises \`${parsed.triggerId}\`, so counting it would score zero however many people reach that step.`,
28502
+ fix: triggers.available.length > 0 ? `It raises: ${triggers.available.join(", ")}.` : triggers.why ?? "That step raises nothing a conversion could be counted on."
28503
+ };
28504
+ }
28505
+ function joinFlowConversions(candidates, definitions) {
28506
+ const byKey = /* @__PURE__ */ new Map();
28507
+ for (const definition of definitions) {
28508
+ for (const key of definition.eventKeys) byKey.set(key, definition.name);
28509
+ }
28510
+ return candidates.map((candidate) => ({ ...candidate, countedAs: byKey.get(candidate.eventKey) ?? null }));
28511
+ }
28512
+ function flowConversionHints(slug, rows, noConversionReason) {
28513
+ const counted = rows.filter((row) => row.countedAs !== null);
28514
+ if (counted.length > 0) {
28515
+ return [
28516
+ `Baker counts ${counted.length} of this Form's endings: ${counted.map((row) => `${row.countedAs} (${row.nodeName})`).join(
28517
+ ", "
28518
+ )}. Every other ending here scores nothing \u2014 count another with \`--event <key> --name <outcome>\`, reusing the SAME name to make two Forms one number.`
28519
+ ];
28520
+ }
28521
+ if (noConversionReason) {
28522
+ return [
28523
+ `Nothing here is counted, and that is on purpose: this Form declares no outcome \u2014 "${noConversionReason}". Leave it alone unless that has stopped being true.`
28524
+ ];
28525
+ }
28526
+ const deepestFirst = [...rows].reverse();
28527
+ const suggestion = deepestFirst.find((row) => row.isCompletion && row.nodeType !== "text") ?? deepestFirst.find((row) => row.isCompletion) ?? rows[rows.length - 1];
28528
+ return [
28529
+ `Nothing on "${slug}" is counted, so it converts nobody on every report however many people finish it \u2014 Baker never guesses which ending is the point of a Form.`,
28530
+ ...suggestion ? [
28531
+ `Name its outcome: \`baker analytics conversions --event "${suggestion.eventKey}" --name "<what a person calls it>"\` (that key is "${suggestion.nodeName}"). It applies at once and rescores the whole history; nothing is staged and there is nothing to publish.`
28532
+ ] : [],
28533
+ `If this Form has no outcome of its own \u2014 it hands the visitor to a shop, a portal or a funnel that converts them there \u2014 say so instead, with \`noConversion: { "reason": "\u2026" }\` on its first step.`
28534
+ ];
28535
+ }
28536
+ function flowCoverage(forms, definitions) {
28537
+ return forms.map(({ slug, tree }) => {
28538
+ const rows = joinFlowConversions(flowConversionCandidates(tree, slug), definitions);
28539
+ const declared = tree?.noConversion?.reason;
28540
+ const seen = /* @__PURE__ */ new Set();
28541
+ return {
28542
+ slug,
28543
+ counted: rows.filter((row) => row.countedAs !== null).filter((row) => {
28544
+ const key = `${row.countedAs} ${row.nodeName}`;
28545
+ if (seen.has(key)) return false;
28546
+ seen.add(key);
28547
+ return true;
28548
+ }).map((row) => ({ name: String(row.countedAs), step: row.nodeName })),
28549
+ noConversion: typeof declared === "string" && declared.trim() !== "" ? declared.trim() : null
28550
+ };
28551
+ });
28552
+ }
28553
+ function flowCoverageHints(coverage) {
28554
+ const unset = coverage.filter((row) => row.counted.length === 0 && row.noConversion === null);
28555
+ if (unset.length === 0) return [];
28556
+ return [
28557
+ `${unset.length} of ${coverage.length} Form(s) here count nothing, so every report shows them converting nobody: ${unset.map((row) => row.slug).join(
28558
+ ", "
28559
+ )}. Read each one's endings with \`baker analytics conversions --flow <slug>\` and name the outcome, or declare on its first step why it has none.`
28560
+ ];
28561
+ }
28081
28562
 
28082
28563
  // src/commands/analytics/hints.ts
28083
28564
  var SEVERE_STEP_DROP = 0.6;
@@ -28960,26 +29441,93 @@ Examples:
28960
29441
  }
28961
29442
  });
28962
29443
  })();
29444
+ function localFormKeyRefusal(eventKey) {
29445
+ const parsed = parseEventKey(eventKey);
29446
+ if (!parsed || parsed.family !== "form") return null;
29447
+ if (!flowExists(parsed.flowSlug)) return null;
29448
+ return formKeyRefusal(eventKey, readFlowTree(parsed.flowSlug), parsed.flowSlug);
29449
+ }
29450
+ function refuseUnmatchableKey(eventKey) {
29451
+ const refusal = eventKey === void 0 ? null : localFormKeyRefusal(eventKey);
29452
+ if (!refusal) return;
29453
+ writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message: refusal.message, fix: refusal.fix } });
29454
+ process.exit(1);
29455
+ }
29456
+ function readWorkspaceFlows() {
29457
+ return listFlowSlugs().flatMap((slug) => {
29458
+ try {
29459
+ return [{ slug, tree: JSON.parse(readFileSync12(flowDataPath(slug), "utf-8")) }];
29460
+ } catch {
29461
+ return [];
29462
+ }
29463
+ });
29464
+ }
29465
+ function readFlowConversions2(slug, definitions) {
29466
+ const tree = readFlowTree(slug);
29467
+ const declared = tree.noConversion;
29468
+ const reason = declared && typeof declared === "object" && typeof declared.reason === "string" ? String(declared.reason) : null;
29469
+ return {
29470
+ slug,
29471
+ noConversionReason: reason,
29472
+ endings: joinFlowConversions(flowConversionCandidates(tree, slug), definitions)
29473
+ };
29474
+ }
29475
+ function conversionsEnvelope(args, definitions, candidates) {
29476
+ const flow = args.flowSlug === void 0 ? null : readFlowConversions2(args.flowSlug, definitions);
29477
+ const forms = flow === null ? readWorkspaceFlows() : [];
29478
+ const coverage = flowCoverage(forms, definitions);
29479
+ const hints2 = [
29480
+ ...flow ? flowConversionHints(flow.slug, flow.endings, flow.noConversionReason) : [],
29481
+ ...flowCoverageHints(coverage),
29482
+ ...conversionHints(definitions, candidates, args.candidates),
29483
+ ...namingHints(definitions)
29484
+ ];
29485
+ return {
29486
+ ok: true,
29487
+ data: {
29488
+ definitions,
29489
+ ...flow ? { flow } : {},
29490
+ ...coverage.length > 0 ? { flows: coverage } : {},
29491
+ ...args.candidates ? { candidates } : {}
29492
+ },
29493
+ ...hints2.length > 0 ? { hints: hints2 } : {}
29494
+ };
29495
+ }
28963
29496
  var conversionsCommand3 = (() => {
28964
29497
  const args = {
28965
29498
  event: {
28966
29499
  type: "string",
28967
- description: "The event to start counting, from the `candidates` list: form:<form>:<step>:<trigger>, submit:<form>, page:<name>, or exit:<host>. Requires --name. Applies immediately and scores the WHOLE history, not just from now on \u2014 so counting the right event today also corrects last month's reports",
29500
+ description: "The event to start counting, from the `candidates` or `--flow` list: form:<form>:<step>:<trigger>, submit:<form>, page:<name>, or exit:<host>. Requires --name. REPEATABLE \u2014 pass it several times with one --name to count all of them as that single outcome, which is the whole point of the name. Applies immediately and scores the WHOLE history, not just from now on, so counting the right event today also corrects last month's reports",
28968
29501
  required: false
28969
29502
  },
28970
29503
  name: {
28971
29504
  type: "string",
28972
- description: "What to call this outcome on every report \u2014 'Booked a call', 'Quote requested'. Reuse the SAME name on two events and they become one row and one number, which is how a call booked on three different Forms reads as one outcome rather than three",
29505
+ description: "What to call this outcome on every report \u2014 'Booked a call', 'Quote requested'. Baker groups by EXACT name: reuse one this company already counts and the two become one row and one number, which is how a call booked on three Forms reads as one outcome. Read the existing names first (run with no flags) and reuse one before inventing another; a name that only differs by case, accent, punctuation or a plural is refused, because it would split the number in two",
29506
+ required: false
29507
+ },
29508
+ rename: {
29509
+ type: "string",
29510
+ description: "An outcome's current name. With --to, renames every event counted under it. Renaming ONTO a name that already exists MERGES them into one row and one number \u2014 the way to fix a company whose Forms each invented their own word for the same outcome",
29511
+ required: false
29512
+ },
29513
+ to: {
29514
+ type: "string",
29515
+ description: "The new name for --rename",
29516
+ required: false
29517
+ },
29518
+ "remove-name": {
29519
+ type: "string",
29520
+ description: "Stop counting an outcome entirely, by name \u2014 removes every event counted under it. Use --remove for a single event key instead",
28973
29521
  required: false
28974
29522
  },
28975
29523
  "count-mode": {
28976
29524
  type: "string",
28977
- description: "once_per_visit (default) or every_time. Ask whether doing it twice in one visit is one outcome or two: a booking clicked twice is one booking, a guide downloaded twice is two downloads. Getting this wrong inflates the rate every budget is set by",
29525
+ description: "once_per_visit (default) or every_time. Ask whether doing it twice in one visit is one outcome or two: a booking clicked twice is one booking, a guide downloaded twice is two downloads. Getting this wrong inflates the rate every budget is set by. Pass it with --event and no --name to change the mode of something already counted, keeping its name",
28978
29526
  required: false
28979
29527
  },
28980
29528
  remove: {
28981
29529
  type: "string",
28982
- description: "An event key to stop counting. Removes it from the history too, for the same reason adding it fills the history in",
29530
+ description: "An event key to stop counting. REPEATABLE. Removes it from the history too, for the same reason adding it fills the history in",
28983
29531
  required: false
28984
29532
  },
28985
29533
  candidates: {
@@ -28987,6 +29535,11 @@ var conversionsCommand3 = (() => {
28987
29535
  description: "Also list every event these pages actually produced, ranked by volume, with whether it is already counted. This is where an event key comes from \u2014 do not invent one. Off by default because a busy company produces hundreds of rows",
28988
29536
  required: false
28989
29537
  },
29538
+ flow: {
29539
+ type: "string",
29540
+ description: "A Form slug. Lists that Form's own endings already spelled as event keys, each with the name Baker counts it under or nothing \u2014 read off the Form's file, so it works on a Form built minutes ago that has never been visited, which is exactly when --candidates is empty. Use it whenever you create or edit a Form: it is the only way to see that the Form counts nothing before it is published counting nothing",
29541
+ required: false
29542
+ },
28990
29543
  days: {
28991
29544
  type: "string",
28992
29545
  description: "Lookback for the counts and for --candidates. Default 30",
@@ -28995,34 +29548,33 @@ var conversionsCommand3 = (() => {
28995
29548
  };
28996
29549
  registerSchema({
28997
29550
  command: "analytics.conversions",
28998
- description: "Read or change what this company counts as a conversion",
29551
+ description: "Read or change what this company counts as a conversion. Also `--flow <slug>` for one Form's own endings as event keys, whether or not any of them has fired.",
28999
29552
  args
29000
29553
  });
29001
29554
  return defineCommand95({
29002
29555
  meta: {
29003
29556
  name: "conversions",
29004
- description: "What this company counts as a conversion, and what each one produced. Run with no flags first: an empty `definitions` list means every conversion number in every other report is zero \u2014 not because nobody converted, but because nothing is marked as converting, and Baker never guesses. Add `--candidates` to see everything these pages actually do (every Form step, every data-baker-* event, every outbound destination) with counts and whether it is already counted; that list is where an event key comes from, so never invent one. Then `--event <key> --name 'Booked a call'` to count it. It applies IMMEDIATELY and retroactively \u2014 the whole history is rescored, so a company that has been running for a month gets a month of correct numbers the moment you name the right event. Nothing here is staged and publishing is not involved. Two events given the SAME --name are one row and one number. `--remove <key>` stops counting one."
29557
+ description: "What this company counts as a conversion, and what each one produced. Run with no flags first: an empty `definitions` list means every conversion number in every other report is zero \u2014 not because nobody converted, but because nothing is marked as converting, and Baker never guesses. Add `--candidates` to see everything these pages actually do (every Form step, every data-baker-* event, every outbound destination) with counts and whether it is already counted; that list is where an event key comes from, so never invent one. Then `--event <key> --name 'Booked a call'` to count it. It applies IMMEDIATELY and retroactively \u2014 the whole history is rescored, so a company that has been running for a month gets a month of correct numbers the moment you name the right event. Nothing here is staged and publishing is not involved. Two events given the SAME --name are one row and one number. `--remove <key>` stops counting one. `--flow <slug>` answers the other direction \u2014 what one Form counts, with every ending it has already spelled as an event key, read from the Form itself so it works before a single visitor has seen it. Run it whenever you create or edit a Form: a Form nobody named an outcome for is published converting nobody, and no report says so."
29005
29558
  },
29006
29559
  args,
29007
29560
  run: async ({ args: raw }) => {
29008
29561
  try {
29009
29562
  const args2 = parseConversionsArgs(raw, process.argv);
29010
- const write2 = conversionsWrite(args2);
29011
- if (write2) {
29012
- await apiPost("/api/analytics/conversions", write2);
29013
- }
29563
+ for (const eventKey of args2.eventKeys) refuseUnmatchableKey(eventKey);
29564
+ const before = await apiPost("/api/analytics/conversions", {});
29565
+ const write2 = conversionsWrite(args2, before.data.definitions);
29566
+ const stored = write2 ? (await apiPost("/api/analytics/conversions", write2)).data.definitions : before.data.definitions;
29014
29567
  const report = await apiPost("/api/analytics/query", {
29015
29568
  preset: "conversions",
29016
29569
  days: args2.days
29017
29570
  });
29018
- const definitions = report.data.conversions ?? [];
29019
- const candidates = report.data.eventCatalog ?? [];
29020
- const hints2 = conversionHints(definitions, candidates, args2.candidates);
29021
- writeJsonEnvelope({
29022
- ok: true,
29023
- data: { definitions, ...args2.candidates ? { candidates } : {} },
29024
- ...hints2.length > 0 ? { hints: hints2 } : {}
29025
- });
29571
+ writeJsonEnvelope(
29572
+ conversionsEnvelope(
29573
+ args2,
29574
+ mergeStoredWithCounts(stored, report.data.conversions ?? []),
29575
+ report.data.eventCatalog ?? []
29576
+ )
29577
+ );
29026
29578
  } catch (err) {
29027
29579
  if (err instanceof ConversionArgsError) {
29028
29580
  handleError(new ApiError("VALIDATION_ERROR", err.message));
@@ -29460,11 +30012,11 @@ function unwrap(response) {
29460
30012
  }
29461
30013
  return response.data;
29462
30014
  }
29463
- async function readAvatars(path42, params) {
29464
- return unwrap(await apiGet(path42, params));
30015
+ async function readAvatars(path44, params) {
30016
+ return unwrap(await apiGet(path44, params));
29465
30017
  }
29466
- async function writeAvatars(path42, body) {
29467
- return unwrap(await apiPost(path42, body));
30018
+ async function writeAvatars(path44, body) {
30019
+ return unwrap(await apiPost(path44, body));
29468
30020
  }
29469
30021
 
29470
30022
  // src/commands/avatars/create.ts
@@ -30298,12 +30850,12 @@ function missingFontFiles(urls, available) {
30298
30850
  function planFontAdoption(sources, families) {
30299
30851
  const wanted = new Map(families.map((family) => [normalizeFamily(family), family]));
30300
30852
  const byFamily = /* @__PURE__ */ new Map();
30301
- for (const { path: path42, source } of sources) {
30302
- const dir = posix.dirname(path42);
30853
+ for (const { path: path44, source } of sources) {
30854
+ const dir = posix.dirname(path44);
30303
30855
  for (const face of declaredFontFaces(source)) {
30304
30856
  if (!wanted.has(face.family)) continue;
30305
30857
  const perFile = byFamily.get(face.family) ?? /* @__PURE__ */ new Map();
30306
- perFile.set(path42, [...perFile.get(path42) ?? [], rebaseFontFaceSrc(face.block, dir)]);
30858
+ perFile.set(path44, [...perFile.get(path44) ?? [], rebaseFontFaceSrc(face.block, dir)]);
30307
30859
  byFamily.set(face.family, perFile);
30308
30860
  }
30309
30861
  }
@@ -31324,10 +31876,10 @@ import path15 from "path";
31324
31876
  import { defineCommand as defineCommand107 } from "citty";
31325
31877
 
31326
31878
  // src/commands/canvas/normalize-paths.ts
31327
- import { existsSync as existsSync3, realpathSync } from "fs";
31879
+ import { existsSync as existsSync4, realpathSync } from "fs";
31328
31880
  import { writeFile as writeFile2 } from "fs/promises";
31329
31881
  import path5 from "path";
31330
- function findWorkspaceRoot(startDir, exists2 = existsSync3, maxDepth = 12) {
31882
+ function findWorkspaceRoot(startDir, exists2 = existsSync4, maxDepth = 12) {
31331
31883
  let dir = path5.resolve(startDir);
31332
31884
  for (let i = 0; i < maxDepth; i++) {
31333
31885
  if (exists2(path5.join(dir, "package.json"))) return dir;
@@ -35277,10 +35829,10 @@ function runDirsToPrune(entries, keep, currentRunId) {
35277
35829
  return runs.slice(0, Math.max(0, runs.length - keep));
35278
35830
  }
35279
35831
  async function pruneOldRuns(outputsDir, keep, currentRunId, log) {
35280
- const { readdir: readdir12 } = await import("fs/promises");
35832
+ const { readdir: readdir14 } = await import("fs/promises");
35281
35833
  let entries;
35282
35834
  try {
35283
- entries = await readdir12(outputsDir);
35835
+ entries = await readdir14(outputsDir);
35284
35836
  } catch {
35285
35837
  return;
35286
35838
  }
@@ -36972,7 +37524,7 @@ function routeVideoModel(input) {
36972
37524
  import { execFile as execFile2 } from "child_process";
36973
37525
  import { mkdtemp, readdir as readdir7, readFile as readFile14, rm as rm5 } from "fs/promises";
36974
37526
  import { tmpdir } from "os";
36975
- import { join as join2 } from "path";
37527
+ import { join as join3 } from "path";
36976
37528
  import { promisify as promisify2 } from "util";
36977
37529
  var execFileAsync2 = promisify2(execFile2);
36978
37530
  var PYSCENEDETECT_THRESHOLD = 18;
@@ -37025,7 +37577,7 @@ function parsePySceneDetectCsvCuts(csv) {
37025
37577
  return [...new Set(cuts)].sort((a, b) => a - b);
37026
37578
  }
37027
37579
  async function runSceneDetectOnce(filePath, threshold, minSceneLenS, timeoutMs) {
37028
- const outDir = await mkdtemp(join2(tmpdir(), "baker-scenedetect-"));
37580
+ const outDir = await mkdtemp(join3(tmpdir(), "baker-scenedetect-"));
37029
37581
  try {
37030
37582
  await execFileAsync2(
37031
37583
  "scenedetect",
@@ -37046,7 +37598,7 @@ async function runSceneDetectOnce(filePath, threshold, minSceneLenS, timeoutMs)
37046
37598
  );
37047
37599
  const csvName = (await readdir7(outDir)).find((f) => f.toLowerCase().endsWith(".csv"));
37048
37600
  if (!csvName) return [];
37049
- return parsePySceneDetectCsvCuts(await readFile14(join2(outDir, csvName), "utf-8"));
37601
+ return parsePySceneDetectCsvCuts(await readFile14(join3(outDir, csvName), "utf-8"));
37050
37602
  } finally {
37051
37603
  await rm5(outDir, { recursive: true, force: true });
37052
37604
  }
@@ -37070,9 +37622,9 @@ async function detectSceneCutsPySceneDetect(filePath, opts = {}) {
37070
37622
  }
37071
37623
 
37072
37624
  // src/commands/canvas/composition-path.ts
37073
- import { existsSync as existsSync4 } from "fs";
37625
+ import { existsSync as existsSync5 } from "fs";
37074
37626
  import path21 from "path";
37075
- function resolveShippedCanvasDir(name, startDir, exists2 = existsSync4, maxDepth = 8) {
37627
+ function resolveShippedCanvasDir(name, startDir, exists2 = existsSync5, maxDepth = 8) {
37076
37628
  const rel = path21.join("canvas", name);
37077
37629
  let dir = startDir;
37078
37630
  for (let i = 0; i < maxDepth; i++) {
@@ -38457,6 +39009,10 @@ Full guide: __tooling__/docs/tools/baker/creatives.md`
38457
39009
  // src/commands/experiment/index.ts
38458
39010
  import { defineCommand as defineCommand119 } from "citty";
38459
39011
 
39012
+ // src/commands/experiment/composition.ts
39013
+ import { readdir as readdir9, readFile as readFile21 } from "fs/promises";
39014
+ import path27 from "path";
39015
+
38460
39016
  // src/commands/landing/import-graph.ts
38461
39017
  import { readdir as readdir8, readFile as readFile20, stat as stat4 } from "fs/promises";
38462
39018
  import path26 from "path";
@@ -38566,10 +39122,51 @@ async function readComposition(root, controlSlug, variantSlug2) {
38566
39122
  return void 0;
38567
39123
  }
38568
39124
  }
39125
+ var FLOW_REFERENCE_RES = [
39126
+ /["'][^"']*flow-engine\/flows\/([A-Za-z0-9._-]+)/g,
39127
+ /data-flow-form\s*=\s*["']([^"']+)["']/g,
39128
+ /flowName\s*=\s*["']([^"']+)["']/g
39129
+ ];
39130
+ function formsReferenced(texts, knownFlows2) {
39131
+ const found = /* @__PURE__ */ new Set();
39132
+ for (const text2 of texts) {
39133
+ for (const re2 of FLOW_REFERENCE_RES) {
39134
+ for (const match of text2.matchAll(re2)) {
39135
+ const flow = match[1];
39136
+ if (flow && knownFlows2.has(flow)) found.add(flow);
39137
+ }
39138
+ }
39139
+ }
39140
+ return [...found].sort();
39141
+ }
39142
+ function formsOnlyIn(these, those) {
39143
+ const other = new Set(those);
39144
+ return these.filter((flow) => !other.has(flow));
39145
+ }
39146
+ async function knownFlows(root) {
39147
+ try {
39148
+ const entries = await readdir9(path27.join(root, "src/lib/flow-engine/flows"), { withFileTypes: true });
39149
+ return new Set(entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name));
39150
+ } catch {
39151
+ return /* @__PURE__ */ new Set();
39152
+ }
39153
+ }
39154
+ async function readForms(root, slug) {
39155
+ try {
39156
+ const [files, flows] = await Promise.all([landingGraphFiles(root, slug), knownFlows(root)]);
39157
+ if (flows.size === 0) return [];
39158
+ const texts = await Promise.all(
39159
+ [...files].filter((file) => /\.(astro|ts|tsx|js|mjs)$/.test(file)).map((file) => readFile21(path27.join(root, file), "utf8"))
39160
+ );
39161
+ return formsReferenced(texts, flows);
39162
+ } catch {
39163
+ return [];
39164
+ }
39165
+ }
38569
39166
 
38570
39167
  // src/commands/experiment/fold.ts
38571
- import { cp as cp2, mkdir as mkdir8, readdir as readdir9, readFile as readFile21, rm as rm7, stat as stat5, writeFile as writeFile11 } from "fs/promises";
38572
- import path28 from "path";
39168
+ import { cp as cp2, mkdir as mkdir8, readdir as readdir10, readFile as readFile22, rm as rm7, stat as stat5, writeFile as writeFile11 } from "fs/promises";
39169
+ import path29 from "path";
38573
39170
 
38574
39171
  // ../api/src/experiments/variants.ts
38575
39172
  var VARIANT_SEPARATOR = "--";
@@ -38594,21 +39191,21 @@ function nextVariantNumber(page, existingSlugs, archived) {
38594
39191
  }
38595
39192
 
38596
39193
  // src/commands/landing/repoint.ts
38597
- import path27 from "path";
39194
+ import path28 from "path";
38598
39195
  var QUOTED_RELATIVE = /(['"])(\.\.?\/[^'"]*)\1/g;
38599
39196
  function isInside3(abs, dir) {
38600
- return abs === dir || abs.startsWith(dir + path27.sep);
39197
+ return abs === dir || abs.startsWith(dir + path28.sep);
38601
39198
  }
38602
39199
  function toSpecifier(from, to) {
38603
- const rel = path27.relative(from, to).split(path27.sep).join("/");
39200
+ const rel = path28.relative(from, to).split(path28.sep).join("/");
38604
39201
  return rel.startsWith(".") ? rel : `./${rel}`;
38605
39202
  }
38606
39203
  function repointSpecifier(spec, opts) {
38607
39204
  if (!spec.startsWith(".")) return spec;
38608
- const abs = path27.resolve(opts.fromDir, spec);
39205
+ const abs = path28.resolve(opts.fromDir, spec);
38609
39206
  if (opts.forkedTargets?.has(abs)) {
38610
- const withinControl = path27.relative(opts.controlDir, abs);
38611
- return toSpecifier(opts.toDir, path27.resolve(opts.variantDir, withinControl));
39207
+ const withinControl = path28.relative(opts.controlDir, abs);
39208
+ return toSpecifier(opts.toDir, path28.resolve(opts.variantDir, withinControl));
38612
39209
  }
38613
39210
  if (!isInside3(abs, opts.controlDir)) return spec;
38614
39211
  return toSpecifier(opts.toDir, abs);
@@ -38620,9 +39217,9 @@ function repointFile(text2, opts) {
38620
39217
  }
38621
39218
  function repointMoved(text2, opts) {
38622
39219
  return text2.replace(QUOTED_RELATIVE, (_match, quote, spec) => {
38623
- const abs = path27.resolve(opts.fromDir, spec);
38624
- const within = path27.relative(opts.movedFrom, abs);
38625
- const target = within.startsWith("..") || path27.isAbsolute(within) ? abs : path27.resolve(opts.movedTo, within);
39220
+ const abs = path28.resolve(opts.fromDir, spec);
39221
+ const within = path28.relative(opts.movedFrom, abs);
39222
+ const target = within.startsWith("..") || path28.isAbsolute(within) ? abs : path28.resolve(opts.movedTo, within);
38626
39223
  return `${quote}${toSpecifier(opts.toDir, target)}${quote}`;
38627
39224
  });
38628
39225
  }
@@ -38631,7 +39228,7 @@ function repointMoved(text2, opts) {
38631
39228
  var ARCHIVE_ROOT = "src/_variants";
38632
39229
  var QUOTED_RELATIVE2 = /(['"])(\.\.?\/[^'"]*)\1/g;
38633
39230
  function posix2(p) {
38634
- return p.split(path28.sep).join("/");
39231
+ return p.split(path29.sep).join("/");
38635
39232
  }
38636
39233
  async function isDir(p) {
38637
39234
  try {
@@ -38643,46 +39240,46 @@ async function isDir(p) {
38643
39240
  async function filesUnder(dir, prefix = "") {
38644
39241
  let entries;
38645
39242
  try {
38646
- entries = await readdir9(dir, { withFileTypes: true });
39243
+ entries = await readdir10(dir, { withFileTypes: true });
38647
39244
  } catch {
38648
39245
  return [];
38649
39246
  }
38650
39247
  const out = [];
38651
39248
  for (const entry of entries) {
38652
39249
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
38653
- if (entry.isDirectory()) out.push(...await filesUnder(path28.join(dir, entry.name), rel));
39250
+ if (entry.isDirectory()) out.push(...await filesUnder(path29.join(dir, entry.name), rel));
38654
39251
  else out.push(rel);
38655
39252
  }
38656
39253
  return out.sort();
38657
39254
  }
38658
39255
  var TEXT_EXT = /\.(astro|ts|tsx|js|jsx|mjs|cjs|vue|svelte|md|json|css)$/;
38659
39256
  function archiveRelPath(abs, base) {
38660
- if (!abs.startsWith(base + path28.sep)) return null;
38661
- return posix2(path28.relative(base, abs));
39257
+ if (!abs.startsWith(base + path29.sep)) return null;
39258
+ return posix2(path29.relative(base, abs));
38662
39259
  }
38663
39260
  async function archiveArm(opts) {
38664
39261
  const { root, armDir, pageDir, archiveDir, armSlug } = opts;
38665
39262
  const targets = await archiveContents({ root, armDir, pageDir, armSlug });
38666
- const archivedBySource = new Map([...targets].map(([rel, abs]) => [abs, path28.join(archiveDir, rel)]));
39263
+ const archivedBySource = new Map([...targets].map(([rel, abs]) => [abs, path29.join(archiveDir, rel)]));
38667
39264
  const written = [];
38668
39265
  for (const [rel, abs] of [...targets].sort()) {
38669
- const dest = path28.join(archiveDir, rel);
38670
- await mkdir8(path28.dirname(dest), { recursive: true });
39266
+ const dest = path29.join(archiveDir, rel);
39267
+ await mkdir8(path29.dirname(dest), { recursive: true });
38671
39268
  if (!TEXT_EXT.test(rel)) {
38672
39269
  await cp2(abs, dest);
38673
39270
  } else {
38674
- const text2 = await readFile21(abs, "utf8");
39271
+ const text2 = await readFile22(abs, "utf8");
38675
39272
  await writeFile11(dest, repointIntoArchive(text2, abs, dest, archivedBySource), "utf8");
38676
39273
  }
38677
- written.push(posix2(path28.relative(root, dest)));
39274
+ written.push(posix2(path29.relative(root, dest)));
38678
39275
  }
38679
39276
  return written;
38680
39277
  }
38681
39278
  async function archiveContents(opts) {
38682
39279
  const { root, armDir, pageDir, armSlug } = opts;
38683
39280
  const graph = await landingGraphFiles(root, armSlug);
38684
- const reachable = new Set([...graph].map((rel) => path28.resolve(root, rel)));
38685
- for (const rel of await filesUnder(armDir)) reachable.add(path28.join(armDir, rel));
39281
+ const reachable = new Set([...graph].map((rel) => path29.resolve(root, rel)));
39282
+ for (const rel of await filesUnder(armDir)) reachable.add(path29.join(armDir, rel));
38686
39283
  const sorted = [...reachable].sort();
38687
39284
  const targets = /* @__PURE__ */ new Map();
38688
39285
  for (const base of [pageDir, armDir]) {
@@ -38696,9 +39293,9 @@ async function archiveContents(opts) {
38696
39293
  }
38697
39294
  function repointIntoArchive(text2, sourceAbs, destAbs, archivedBySource) {
38698
39295
  return text2.replace(QUOTED_RELATIVE2, (match, quote, spec) => {
38699
- const resolved = resolveWithExtensions(path28.resolve(path28.dirname(sourceAbs), spec), archivedBySource);
39296
+ const resolved = resolveWithExtensions(path29.resolve(path29.dirname(sourceAbs), spec), archivedBySource);
38700
39297
  if (resolved === null) return match;
38701
- const rel = posix2(path28.relative(path28.dirname(destAbs), resolved));
39298
+ const rel = posix2(path29.relative(path29.dirname(destAbs), resolved));
38702
39299
  return `${quote}${rel.startsWith(".") ? rel : `./${rel}`}${quote}`;
38703
39300
  });
38704
39301
  }
@@ -38714,25 +39311,25 @@ async function promoteVariantFiles(opts) {
38714
39311
  const written = [];
38715
39312
  for (const rel of await filesUnder(variantDir)) {
38716
39313
  if (rel === "_definition.md") continue;
38717
- const from = path28.join(variantDir, rel);
38718
- const to = path28.join(pageDir, rel);
38719
- await mkdir8(path28.dirname(to), { recursive: true });
39314
+ const from = path29.join(variantDir, rel);
39315
+ const to = path29.join(pageDir, rel);
39316
+ await mkdir8(path29.dirname(to), { recursive: true });
38720
39317
  if (!TEXT_EXT.test(rel)) {
38721
39318
  await cp2(from, to);
38722
39319
  } else {
38723
- const text2 = await readFile21(from, "utf8");
39320
+ const text2 = await readFile22(from, "utf8");
38724
39321
  await writeFile11(
38725
39322
  to,
38726
39323
  repointMoved(text2, {
38727
- fromDir: path28.dirname(from),
38728
- toDir: path28.dirname(to),
39324
+ fromDir: path29.dirname(from),
39325
+ toDir: path29.dirname(to),
38729
39326
  movedFrom: variantDir,
38730
39327
  movedTo: pageDir
38731
39328
  }),
38732
39329
  "utf8"
38733
39330
  );
38734
39331
  }
38735
- written.push(posix2(path28.relative(root, to)));
39332
+ written.push(posix2(path29.relative(root, to)));
38736
39333
  }
38737
39334
  return written;
38738
39335
  }
@@ -38756,8 +39353,8 @@ function archiveRecord(target, variantNumber) {
38756
39353
  `;
38757
39354
  }
38758
39355
  async function foldExperiment(root, target) {
38759
- const pageDir = path28.resolve(root, "src/pages", target.landingSlug);
38760
- const variantDir = path28.resolve(root, "src/pages", target.variantSlug);
39356
+ const pageDir = path29.resolve(root, "src/pages", target.landingSlug);
39357
+ const variantDir = path29.resolve(root, "src/pages", target.variantSlug);
38761
39358
  const variantNumber = variantNumberOf(target.variantSlug);
38762
39359
  if (variantNumber === null) {
38763
39360
  return {
@@ -38771,7 +39368,7 @@ async function foldExperiment(root, target) {
38771
39368
  return { error: `src/pages/${target.variantSlug}/ is already gone \u2014 this test looks folded in already` };
38772
39369
  }
38773
39370
  const archiveKey = target.landingInternalId ?? target.landingSlug;
38774
- const archiveDir = path28.resolve(root, ARCHIVE_ROOT, archiveKey, String(variantNumber));
39371
+ const archiveDir = path29.resolve(root, ARCHIVE_ROOT, archiveKey, String(variantNumber));
38775
39372
  await rm7(archiveDir, { recursive: true, force: true });
38776
39373
  await mkdir8(archiveDir, { recursive: true });
38777
39374
  const written = await archiveArm({
@@ -38781,8 +39378,8 @@ async function foldExperiment(root, target) {
38781
39378
  archiveDir,
38782
39379
  armSlug: target.survivor === "variant" ? target.landingSlug : target.variantSlug
38783
39380
  });
38784
- await writeFile11(path28.join(archiveDir, "_variant.json"), archiveRecord(target, variantNumber), "utf8");
38785
- written.push(posix2(path28.join(ARCHIVE_ROOT, archiveKey, String(variantNumber), "_variant.json")));
39381
+ await writeFile11(path29.join(archiveDir, "_variant.json"), archiveRecord(target, variantNumber), "utf8");
39382
+ written.push(posix2(path29.join(ARCHIVE_ROOT, archiveKey, String(variantNumber), "_variant.json")));
38786
39383
  if (target.survivor === "variant") {
38787
39384
  written.push(...await promoteVariantFiles({ root, pageDir, variantDir }));
38788
39385
  }
@@ -38790,59 +39387,48 @@ async function foldExperiment(root, target) {
38790
39387
  return {
38791
39388
  experimentId: target.experimentId,
38792
39389
  page: target.landingSlug,
38793
- archivedTo: posix2(path28.join(ARCHIVE_ROOT, archiveKey, String(variantNumber))),
39390
+ archivedTo: posix2(path29.join(ARCHIVE_ROOT, archiveKey, String(variantNumber))),
38794
39391
  written,
38795
39392
  removed: [`src/pages/${target.variantSlug}`]
38796
39393
  };
38797
39394
  }
38798
39395
 
38799
- // src/commands/experiment/goal.ts
38800
- function parseEventGoal(argument) {
38801
- const [name, filter] = argument.split("/");
38802
- if (!name) return { error: "Name the event: --goal event:request_demo" };
38803
- if (!filter) return { kind: "custom_event", name };
38804
- const separator = filter.indexOf("=");
38805
- if (separator === -1) {
38806
- return { error: `Write the property as key=value: --goal event:${name}/section=pricing` };
38807
- }
38808
- return { kind: "custom_event", name, property: filter.slice(0, separator), value: filter.slice(separator + 1) };
38809
- }
38810
- function parseGoal(raw) {
38811
- if (raw === void 0 || raw === "") return void 0;
38812
- const separator = raw.indexOf(":");
38813
- const kind = separator === -1 ? raw : raw.slice(0, separator);
38814
- const argument = separator === -1 ? "" : raw.slice(separator + 1);
38815
- if (kind === "leads") return argument ? { kind: "conversion", flowSlug: argument } : { kind: "conversion" };
38816
- if (kind === "click") return argument ? { kind: "outbound_click", targetHost: argument } : { kind: "outbound_click" };
38817
- if (kind === "event") return parseEventGoal(argument);
38818
- return {
38819
- error: `Unknown goal "${raw}". Use leads, leads:<form>, event:<name>, event:<name>/<key>=<value>, click, or click:<host>`
38820
- };
38821
- }
38822
-
38823
39396
  // src/commands/experiment/hints.ts
38824
- var RUNNING_HINT = {
39397
+ var LIVE_HINT = {
38825
39398
  keep_running: (row) => `${row.experimentId} cannot yet tell the two pages apart \u2014 do not read its numbers as a result, and do not end it hoping for one. ${describeWait(row)}`,
38826
- winner: (row) => `${row.experimentId} has a winner. Run \`baker experiment finish --id ${row.experimentId}\` to send all the traffic to it. That only re-routes between two pages already published \u2014 it does not edit either.`,
38827
- no_difference: (row) => `${row.experimentId} measured the two pages as alike \u2014 it collected enough traffic to see the change it was looking for, and there was none. Finish it (the page you have stays live) and test a bigger change \u2014 a headline, an offer, the shape of the page, not a button colour.`,
38828
- stopped_early_harmful: (row) => `${row.experimentId} is doing damage. Finish it now \u2014 traffic goes back to the original page.`,
38829
- // Unreachable in practice — only `finish --abandon` produces it, and that
38830
- // leaves the test finished. The Record is exhaustive by type so a verdict
38831
- // added upstream fails to compile here rather than printing nothing.
38832
- abandoned: (row) => `${row.experimentId} was stopped before it concluded and found nothing.`,
38833
- invalid: (row) => `${row.experimentId} cannot be read: ${row.invalidReason === "crossover" ? "too many visitors saw both versions" : "the traffic did not split evenly"}. Finish it and start again rather than reporting anything from it.`
39399
+ winner: (row) => row.winner === "variant" ? `${row.experimentId} has a winner: the new variant. Run \`baker experiment finish --id ${row.experimentId} --keep variant\` to show it to everyone, then \`baker experiment fold\`.` : `${row.experimentId} has a winner: the page as it is. Run \`baker experiment finish --id ${row.experimentId} --keep original\` to put all the traffic back on it.`,
39400
+ no_difference: (row) => `${row.experimentId} measured the two pages as alike \u2014 it collected enough traffic to see the change it was looking for, and there was none. End it with \`--keep original\` and test a bigger change \u2014 a headline, an offer, the shape of the page, not a button colour.`,
39401
+ stopped_early_harmful: (row) => `${row.experimentId} is doing damage and is STILL being shown to visitors. Run \`baker experiment finish --id ${row.experimentId} --keep original\` now.`,
39402
+ // Unreachable on a live test — only ending one produces it. The Record is
39403
+ // exhaustive by type so a verdict added upstream fails to compile here rather
39404
+ // than printing nothing.
39405
+ inconclusive: (row) => `${row.experimentId} was ended before it concluded and found nothing.`,
39406
+ invalid: (row) => `${row.experimentId} cannot be read: ${row.invalidReason === "crossover" ? "too many visitors saw both versions" : "the traffic did not split evenly"}. End it with \`--keep original\` and start again rather than reporting anything from it.`
38834
39407
  };
38835
39408
  function describeWait(row) {
38836
39409
  if (row.willNotConclude) {
38837
- return "This page's traffic will not settle it at any point, so abandon it with `--abandon` and test a bigger change.";
39410
+ return "This page's traffic will not settle it at any point, so end it with `--keep original` and test a bigger change.";
38838
39411
  }
38839
39412
  if (row.earliestDecisionAt === null) {
38840
39413
  return "This page has produced no visitors yet, so there is nothing to project a finish date from.";
38841
39414
  }
38842
39415
  return `Earliest it could say anything: ${new Date(row.earliestDecisionAt).toISOString().slice(0, 10)}.`;
38843
39416
  }
39417
+ function pausedNote(row) {
39418
+ return row.status === "paused" ? `${row.experimentId} is paused \u2014 everyone sees the original. \`baker experiment resume --id ${row.experimentId}\` restarts the split with the same sides. ` : "";
39419
+ }
39420
+ function unlearnedHint(experiments) {
39421
+ const unlearned = experiments.filter(
39422
+ (row) => row.status === "finished" && row.learning === null && row.verdict !== "inconclusive"
39423
+ );
39424
+ if (unlearned.length === 0) return null;
39425
+ const ids = unlearned.map((row) => row.experimentId).join(", ");
39426
+ return `${unlearned.length === 1 ? "One finished test has" : `${unlearned.length} finished tests have`} no learning written down yet (${ids}). Say what it taught in one or two sentences \u2014 what the client should believe about this page now \u2014 with \`baker experiment update --id <id> --learning "\u2026"\`. It is what \`plan\` hands the next test.`;
39427
+ }
38844
39428
  function buildStatusHints(experiments) {
38845
- const hints2 = experiments.filter((row) => row.status === "running").map((row) => RUNNING_HINT[row.verdict](row));
39429
+ const hints2 = experiments.filter((row) => row.status !== "finished").map((row) => pausedNote(row) + LIVE_HINT[row.verdict](row));
39430
+ const unlearned = unlearnedHint(experiments);
39431
+ if (unlearned) hints2.push(unlearned);
38846
39432
  if (experiments.length === 0) {
38847
39433
  hints2.push(
38848
39434
  "No tests yet. `baker experiment plan --landing <slug> --variant <slug>` says whether a page has enough traffic for one before you build the alternative."
@@ -38853,40 +39439,122 @@ function buildStatusHints(experiments) {
38853
39439
  function asLift(value) {
38854
39440
  return `+${Math.round(value * 100)}%`;
38855
39441
  }
38856
- function windowHint(days, detectable) {
38857
- if (days === void 0) return null;
38858
- if (detectable === null || detectable === void 0) {
38859
- return `In ${days} days this page would not gather enough visitors to settle anything, at any size of change. The constraint is the traffic, not the test.`;
38860
- }
38861
- return `In ${days} days this page could only settle a change of ${asLift(detectable)} or more. Design the alternative to be at least that different \u2014 a smaller change needs traffic these ${days} days will not deliver, and the test would end saying nothing.`;
38862
- }
38863
- function buildPlanHints(plan, days) {
38864
- const window2 = windowHint(days, plan.detectableLiftInDays);
39442
+ function horizonsHint(plan) {
39443
+ const settled = plan.horizons.filter((horizon) => horizon.detectableLift !== null);
39444
+ if (settled.length === 0) {
39445
+ return "At this page's traffic no window up to 90 days would gather enough visitors to settle anything, at any size of change. The constraint is the traffic, not the test.";
39446
+ }
39447
+ const parts = settled.map((horizon) => `${horizon.days} days \u2192 ${asLift(horizon.detectableLift ?? 0)}`);
39448
+ return `The smallest change each window could settle on this page's traffic: ${parts.join(", ")}. Design the alternative to be at least that different.`;
39449
+ }
39450
+ function conversionsHint(plan) {
39451
+ if (plan.conversions.length <= 1) return null;
39452
+ const others = plan.conversions.filter((entry) => entry.eventKey !== plan.goal);
39453
+ return `This page's visitors also went on to: ${others.map((entry) => `\`${entry.eventKey}\`${entry.name ? ` (\u201C${entry.name}\u201D)` : ""} \u2014 ${entry.convertingVisitors}`).join(
39454
+ ", "
39455
+ )}. Any of them can be the goal (--goal <key>); the named ones are reported beside it on every result, and decide nothing.`;
39456
+ }
39457
+ function pastTestLine(test) {
39458
+ const outcome = {
39459
+ supported: "the idea held",
39460
+ refuted_opposite: "the idea was WRONG \u2014 the change made it worse",
39461
+ refuted_no_effect: "no difference \u2014 the change did not matter",
39462
+ unanswered: "never answered"
39463
+ };
39464
+ const kept = test.survivor === "variant" ? "the new version was kept" : "the original was kept";
39465
+ const learning = test.learning ? ` Learning: ${test.learning}` : "";
39466
+ return `\u201C${test.hypothesis.change}\u201D (${test.experimentId}, ${test.ranForDays} days): ${outcome[test.hypothesisOutcome]}; ${kept}${test.againstVerdict ? " against the numbers" : ""}.${learning}`;
39467
+ }
39468
+ function pastTestsHint(plan) {
39469
+ if (plan.pastTests.length === 0) return null;
39470
+ const lines = plan.pastTests.slice(0, 5).map(pastTestLine);
39471
+ const more = plan.pastTests.length > 5 ? ` \u2026and ${plan.pastTests.length - 5} more \u2014 \`baker experiment history --landing <slug>\` lists them all.` : "";
39472
+ return `This page has been tested before \u2014 read this before choosing what to change, and build on it rather than repeating it: ${lines.join(" ")}${more}`;
39473
+ }
39474
+ function buildPlanHints(plan) {
39475
+ const horizons = horizonsHint(plan);
39476
+ const past = pastTestsHint(plan);
38865
39477
  if (!plan.canRun) {
38866
39478
  return [
38867
39479
  plan.reason ?? "This test cannot run on this page.",
38868
- ...window2 ? [window2] : [],
39480
+ ...horizons && plan.visitors > 0 ? [horizons] : [],
39481
+ ...past ? [past] : [],
38869
39482
  "A test that cannot conclude is worse than no test \u2014 it still produces a number, and somebody acts on it."
38870
39483
  ];
38871
39484
  }
38872
39485
  const hints2 = [
38873
- `This test measures ${plan.goalLabel}. That is fixed when it starts and cannot be changed later.`,
39486
+ ...past ? [past] : [],
39487
+ // First, because it changes how every number below should be read: a plan
39488
+ // sized on a stand-in is a guide, and the agent must say so to the client
39489
+ // rather than quote "about 70 days" as though it were measured.
39490
+ ...plan.warning ? [plan.warning] : [],
39491
+ `This test is read on \u201C${plan.goal}\u201D. That is fixed when it starts and cannot be changed later.`,
38874
39492
  // Fires at the exact moment the hypothesis gets invented, whether or not
38875
39493
  // the agent read the family doc. `plan` says a question CAN be settled here
38876
39494
  // and says nothing about which question is worth asking — and a well-run
38877
- // test of a bad idea costs three weeks and returns `no_difference`.
38878
- "Now decide WHAT to change, from evidence rather than taste. These numbers say which page and which step; they cannot say why. A second opinion answers why \u2014 Microsoft Clarity for what visitors actually do on the page, `baker gsc` for what they searched, `baker ads <platform>` for what the ad promised, `baker ga4` for the client's own measurement. `baker capabilities` lists everything this client has in one call \u2014 the surfaces, and under `tools` everything else that is reachable. Message match \u2014 what the ad or the search promised against what the page opens with \u2014 is the cheapest hypothesis worth testing and is wrong surprisingly often.",
39495
+ // test of a bad idea spends the page's traffic and returns `no_difference`.
39496
+ "Now decide WHAT to change, from evidence rather than taste. These numbers say which page and which step; they cannot say why. A second opinion answers why \u2014 Microsoft Clarity for what visitors actually do on the page, `baker gsc` for what they searched, `baker ads <platform>` for what the ad promised, `baker ga4` for the client's own measurement. `baker capabilities` lists everything this client has in one call. Message match \u2014 what the ad or the search promised against what the page opens with \u2014 is the cheapest hypothesis worth testing and is wrong surprisingly often.",
38879
39497
  // The base always works, so a missing integration is never a reason to
38880
39498
  // stop. Naming what it would have shown is worth more to the client than
38881
39499
  // either a silent gap or a run that waited.
38882
39500
  "If none of that is connected, this page's own drop-off is enough to test on \u2014 say which source you lacked and what it would have told you, then run the test anyway. A test never waits on an integration."
38883
39501
  ];
39502
+ const conversions = conversionsHint(plan);
39503
+ if (conversions) hints2.push(conversions);
38884
39504
  if (plan.estimatedDays !== null && plan.estimatedDays > 60) {
38885
39505
  hints2.push(
38886
- `At this page's traffic it would take about ${plan.estimatedDays} days. Consider testing a bigger change, which needs less traffic to detect.`
39506
+ `At this page's recent traffic it would take about ${plan.estimatedDays} days \u2014 a guide, not a limit: it ends when the numbers are clear, and more traffic (a campaign, ads) shortens it. If the traffic will stay as it is, test a bigger change, which needs fewer visitors to detect.`
39507
+ );
39508
+ }
39509
+ if (horizons) hints2.push(horizons);
39510
+ return hints2;
39511
+ }
39512
+ function buildHistoryHints(tests, landing) {
39513
+ if (tests.length === 0) {
39514
+ return [
39515
+ landing ? `No finished test on \u201C${landing}\u201D yet \u2014 nothing to build on, and nothing to avoid repeating. \`baker experiment plan\` says whether the page can settle one.` : "No finished tests yet. `baker experiment plan --landing <slug> --variant <slug>` says whether a page can settle one."
39516
+ ];
39517
+ }
39518
+ const hints2 = [];
39519
+ const supported = tests.filter((test) => test.hypothesisOutcome === "supported");
39520
+ const refuted = tests.filter((test) => test.hypothesisOutcome === "refuted_opposite");
39521
+ const noEffect = tests.filter((test) => test.hypothesisOutcome === "refuted_no_effect");
39522
+ if (supported.length > 0) {
39523
+ hints2.push(
39524
+ `Held: ${supported.map((test) => `\u201C${test.hypothesis.change}\u201D on ${test.landingSlug}`).join("; ")}. Build on these \u2014 the same insight usually has more in it.`
39525
+ );
39526
+ }
39527
+ if (refuted.length > 0) {
39528
+ hints2.push(
39529
+ `Made it worse: ${refuted.map((test) => `\u201C${test.hypothesis.change}\u201D on ${test.landingSlug}`).join("; ")}. Do not propose these again as they were; the observation behind each may still be right, the change was not.`
39530
+ );
39531
+ }
39532
+ if (noEffect.length > 0) {
39533
+ hints2.push(
39534
+ `Did not matter: ${noEffect.map((test) => `\u201C${test.hypothesis.change}\u201D on ${test.landingSlug}`).join("; ")}. The next change on these pages has to be bigger \u2014 a headline, an offer, the shape of the page.`
39535
+ );
39536
+ }
39537
+ const unlearned = tests.filter((test) => test.learning === null && test.verdict !== "inconclusive");
39538
+ if (unlearned.length > 0) {
39539
+ hints2.push(
39540
+ `${unlearned.length === 1 ? "One test has" : `${unlearned.length} tests have`} no learning written down (${unlearned.map((test) => test.experimentId).join(", ")}). Read its numbers and write one: \`baker experiment update --id <id> --learning "\u2026"\`.`
39541
+ );
39542
+ }
39543
+ return hints2;
39544
+ }
39545
+ function buildStartHints(staged) {
39546
+ const hints2 = [];
39547
+ if (staged.composition && staged.composition.forked.length === 0) {
39548
+ hints2.push(
39549
+ `\u201C${staged.variant}\u201D does not have its own copy of any section \u2014 it renders exactly the same page as \u201C${staged.landing}\u201D, so this test cannot find anything. Fork the section you want to test (\`baker landing variant ${staged.landing} --fork Hero.astro\`) and edit only that file.`
39550
+ );
39551
+ }
39552
+ if (staged.newForms.length > 0 && staged.variantGoal === void 0) {
39553
+ const forms = staged.newForms.map((flow) => `\u201C${flow}\u201D`).join(", ");
39554
+ hints2.push(
39555
+ `\u201C${staged.variant}\u201D uses ${staged.newForms.length === 1 ? "a Form" : "Forms"} the page does not: ${forms}. The test reads the new version on \`${staged.goal}\`, the page's event \u2014 if the new version converts by reaching this Form's ending instead, it can never convert on that key and the test will call it harmful. Say its own event: \`baker experiment update --id <id> --variant-goal submit:${staged.newForms[0]}\` (or the confirmation step's \`form:${staged.newForms[0]}:<node>:<trigger>\` key, from \`baker analytics conversions --candidates\` once the Form has been opened in the preview).`
38887
39556
  );
38888
39557
  }
38889
- if (window2) hints2.push(window2);
38890
39558
  return hints2;
38891
39559
  }
38892
39560
 
@@ -38901,45 +39569,8 @@ var experimentCompositionSchema = z35.object({
38901
39569
 
38902
39570
  // ../api/src/experiments/goal.ts
38903
39571
  import { z as z36 } from "zod";
38904
- var eventNameSchema = z36.string().min(1).max(120);
38905
- var experimentGoalSchema = z36.discriminatedUnion("kind", [
38906
- z36.object({
38907
- kind: z36.literal("conversion"),
38908
- /**
38909
- * Narrow to one Form, by the slug that is its identity.
38910
- *
38911
- * Absent means every marked conversion on the page counts, which is the
38912
- * right default for a landing with one Form and the wrong one for a page
38913
- * carrying both a newsletter signup and a demo request.
38914
- */
38915
- flowSlug: z36.string().max(120).optional()
38916
- }),
38917
- z36.object({
38918
- kind: z36.literal("custom_event"),
38919
- name: eventNameSchema,
38920
- /**
38921
- * Narrow to one value of one of the event's own properties.
38922
- *
38923
- * `section_view` on its own is every section; `section_view` where
38924
- * `section = pricing` is the one that matters. Without this a page would
38925
- * have to encode the answer into the event name to be testable against it,
38926
- * which is precisely what the properties map exists to stop.
38927
- */
38928
- property: z36.string().max(64).optional(),
38929
- value: z36.string().max(255).optional()
38930
- }),
38931
- z36.object({
38932
- kind: z36.literal("outbound_click"),
38933
- /**
38934
- * The destination host, as the row records it. Absent means any exit.
38935
- *
38936
- * A host rather than a full URL because that is what `event_name` carries
38937
- * on an `outbound_click` row — `target_url` has the rest, and matching on
38938
- * it would make the goal depend on query parameters a campaign rewrites.
38939
- */
38940
- targetHost: z36.string().max(255).optional()
38941
- })
38942
- ]);
39572
+ var GOAL_KEY_HELP = "A goal is one event, by its key \u2014 `form:<flow>:<node>:<trigger>`, `submit:<flow>`, `page:<name>` or `exit:<host>`. `baker analytics conversions --candidates` lists the ones these pages produce";
39573
+ var experimentGoalSchema = z36.string().trim().min(1).max(400).refine((key) => parseEventKey(key) !== null, { message: GOAL_KEY_HELP });
38943
39574
 
38944
39575
  // ../api/src/experiments/hypothesis.ts
38945
39576
  import { z as z37 } from "zod";
@@ -38994,10 +39625,31 @@ var experimentHypothesisOutcomeSchema = z37.enum([
38994
39625
  // ../api/src/experiments/wire.ts
38995
39626
  import { z as z38 } from "zod";
38996
39627
  var slugSchema = z38.string().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use a page slug, like `oferta`");
39628
+ var variantSlugSchema = z38.string().min(1).max(130).refine((slug) => {
39629
+ const page = variantOfSlug(slug);
39630
+ return page !== null && slugSchema.safeParse(page).success;
39631
+ }, "Use the variant's slug, `<page>--<n>` \u2014 `baker landing variant <page>` chooses it");
38997
39632
  var experimentPlanRequestSchema = z38.object({
38998
39633
  landingSlug: slugSchema,
38999
- variantSlug: slugSchema,
39634
+ variantSlug: variantSlugSchema,
39635
+ /**
39636
+ * Which of the company's conversions the test is about.
39637
+ *
39638
+ * Optional only when the company counts exactly one outcome, in which case
39639
+ * it is that one. A company counting several has to say which — a test that
39640
+ * "measures conversions" on a page with a newsletter box and a demo request
39641
+ * is measuring two things and deciding on their sum.
39642
+ */
39000
39643
  goal: experimentGoalSchema.optional(),
39644
+ /**
39645
+ * The event the NEW version converts on, when it is not the page's.
39646
+ *
39647
+ * The test asks one question — did more visitors do the thing we want — but
39648
+ * the thing can be a different event on each version: a variant that swaps
39649
+ * the page's Form for a shorter one converts on the new Form's ending. Absent,
39650
+ * the variant is read on `goal`, which is the common case.
39651
+ */
39652
+ variantGoal: experimentGoalSchema.optional(),
39001
39653
  /**
39002
39654
  * The smallest lift worth detecting, relative to the baseline. `0.2` is +20%.
39003
39655
  *
@@ -39008,19 +39660,38 @@ var experimentPlanRequestSchema = z38.object({
39008
39660
  */
39009
39661
  minDetectableRelativeLift: z38.number().gt(0).max(10).optional(),
39010
39662
  /**
39011
- * "I have this many days what could I even see?"
39663
+ * The share of visitors expected to do the goal, as a fraction — `0.05` is
39664
+ * 5% — for a page whose history cannot say.
39012
39665
  *
39013
- * Read-only, and deliberately not a setting. It does NOT shorten the test:
39014
- * the finish line is a number of visitors fixed before the test starts, and a
39015
- * horizon that can be moved is not a horizon. What it does is answer the
39016
- * question a refusal leaves hanging `plan` says a +20% lift needs three
39017
- * weeks this page will not deliver, and this says what those weeks WOULD
39018
- * settle, which is a change somebody can actually go and make.
39666
+ * A page about to get its first campaign has no rate to size on. Without
39667
+ * this the plan stands on the company-wide rate for the outcome, or on a
39668
+ * conventional 3%; with it, on what the person setting the test up knows.
39669
+ * Either way the brief says which, because the sizing is a guide and never a
39670
+ * finish line the test runs until the numbers are clear.
39019
39671
  */
39020
- days: z38.number().int().gt(0).max(365).optional()
39672
+ baselineRate: z38.number().gt(0).lt(1).optional()
39673
+ });
39674
+ var experimentTrafficSchema = z38.enum(["none", "low", "enough"]);
39675
+ var experimentBaselineSourceSchema = z38.enum(["page", "company", "assumed"]);
39676
+ var experimentSizedOnSchema = z38.object({
39677
+ visitors: z38.number(),
39678
+ convertingVisitors: z38.number(),
39679
+ windowDays: z38.number()
39021
39680
  });
39022
39681
  var experimentStartRequestSchema = experimentPlanRequestSchema.extend({
39023
39682
  hypothesis: experimentHypothesisSchema,
39683
+ /**
39684
+ * The two pages' own identities, read off their `_definition.md` by the CLI.
39685
+ *
39686
+ * The backend prefers the `landings` rows, but the variant has no row until
39687
+ * the Session that built it is published — which is after the test is
39688
+ * staged. Without this the variant's identity was never recorded, so a
39689
+ * renamed variant folder detached its running test and a fold archived the
39690
+ * loser under the wrong key. Optional because a workspace that cannot read
39691
+ * its own definition must still be able to start a test.
39692
+ */
39693
+ landingInternalId: z38.string().min(1).max(64).optional(),
39694
+ variantInternalId: z38.string().min(1).max(64).optional(),
39024
39695
  /**
39025
39696
  * What the two arms are made of, read off the import graph by the caller at
39026
39697
  * the moment the test starts.
@@ -39032,34 +39703,61 @@ var experimentStartRequestSchema = experimentPlanRequestSchema.extend({
39032
39703
  */
39033
39704
  composition: experimentCompositionSchema.optional()
39034
39705
  });
39706
+ var experimentKeepSchema = z38.enum(["original", "variant"]);
39707
+ var experimentEndReasonSchema = z38.enum(["numbers_clear", "not_settling", "page_changing", "test_flawed"]);
39708
+ var experimentLearningSchema = z38.string().trim().min(1).max(2e3);
39035
39709
  var experimentFinishRequestSchema = z38.object({
39036
39710
  experimentId: z38.string().min(1).max(64),
39711
+ keep: experimentKeepSchema,
39712
+ /** Why it is being ended. The dashboard always asks; the agent should say. */
39713
+ reason: experimentEndReasonSchema.optional(),
39714
+ learning: experimentLearningSchema.optional()
39715
+ });
39716
+ var experimentPauseRequestSchema = z38.object({
39717
+ experimentId: z38.string().min(1).max(64)
39718
+ });
39719
+ var experimentUpdateRequestSchema = z38.object({
39720
+ experimentId: z38.string().min(1).max(64),
39721
+ because: experimentHypothesisSchema.shape.because.optional(),
39722
+ change: experimentHypothesisSchema.shape.change.optional(),
39723
+ evidence: experimentHypothesisSchema.shape.evidence,
39037
39724
  /**
39038
- * Stop the test, keep the page that is live, and record no result.
39039
- *
39040
- * The only way a person or an agent can end a test that is still
39041
- * `keep_running`, and it is deliberately its own field rather than a
39042
- * `survivor` they get to pick: an abandoned test has no winner, keeps the
39043
- * control, and is recorded as `abandoned` so it can never be reported later
39044
- * as a result. Without the separate flag, "stop this" and "the variant won"
39045
- * would be the same call.
39046
- *
39047
- * **It overrides the verdict, and does not defer to it.** The only outcome it
39048
- * can actually disagree with is a variant win — every other one keeps the
39049
- * control anyway — so reading it as "apply only when the numbers have nothing
39050
- * to say" meant that abandoning a won test handed all the traffic to the
39051
- * variant instead of keeping the original: the opposite of what this field
39052
- * promises, on live visitors, from a flag whose whole purpose is to be the
39053
- * conservative choice. The response reports `discardedVerdict` when it did
39054
- * override one, because throwing away a real finding should be said out loud.
39725
+ * Staged tests only. Spelled out rather than borrowed from the hypothesis
39726
+ * schema, whose `expect` defaults to `increase`: through `.optional()` that
39727
+ * default still fires, so every wording-only update arrived carrying a
39728
+ * direction and was refused for trying to change it.
39055
39729
  */
39056
- abandon: z38.boolean().optional()
39730
+ expect: z38.enum(["increase", "decrease"]).optional(),
39731
+ /** Staged tests only. */
39732
+ goal: experimentGoalSchema.optional(),
39733
+ /** Staged tests only. The new version's own event, when it differs. */
39734
+ variantGoal: experimentGoalSchema.optional(),
39735
+ /** Staged tests only. */
39736
+ minDetectableRelativeLift: z38.number().gt(0).max(10).optional(),
39737
+ /** Finished tests only: what the test taught, written or corrected after the fact. */
39738
+ learning: experimentLearningSchema.optional()
39739
+ });
39740
+ var experimentUpdateResponseSchema = z38.object({
39741
+ /** True when the test had not been published, so its whole plan was re-sized. */
39742
+ staged: z38.boolean(),
39743
+ hypothesisStatement: z38.string()
39057
39744
  });
39058
39745
  var experimentFoldRequestSchema = z38.object({
39059
39746
  /** Which test to fold. Omitted ⇒ every fold this company owes. */
39060
39747
  experimentId: z38.string().max(64).optional(),
39061
39748
  /** The files are written. Baker drops the promotion once the build lands. */
39062
- applied: z38.boolean().optional()
39749
+ applied: z38.boolean().optional(),
39750
+ /**
39751
+ * Which of the owed folds were actually written, on an `applied` call.
39752
+ *
39753
+ * Folding is per page and one page failing must not hold back the others, so
39754
+ * a run that folds two of three has to be able to say which two. Without it
39755
+ * the report was "the company", every owed test was stamped as written, and
39756
+ * the one that failed had its promotion dropped when the build landed — so
39757
+ * its page served the version that LOST, with nothing owed and nothing
39758
+ * logged. Omitted ⇒ everything owed, which is the whole-company case.
39759
+ */
39760
+ experimentIds: z38.array(z38.string().max(64)).max(200).optional()
39063
39761
  });
39064
39762
  var experimentFoldTargetSchema = z38.object({
39065
39763
  experimentId: z38.string(),
@@ -39079,12 +39777,39 @@ var experimentFoldResponseSchema = z38.object({
39079
39777
  });
39080
39778
  var experimentStatusRequestSchema = z38.object({
39081
39779
  experimentId: z38.string().max(64).optional(),
39780
+ /** Only the tests on one page, by its slug. */
39781
+ landingSlug: z38.string().max(200).optional(),
39082
39782
  full: z38.boolean().optional()
39083
39783
  });
39784
+ var experimentHistoryRequestSchema = z38.object({
39785
+ landingSlug: z38.string().max(200).optional(),
39786
+ limit: z38.number().int().positive().max(100).optional()
39787
+ });
39788
+ var experimentPageConversionSchema = z38.object({
39789
+ /** The event, by key — what `--goal` takes. */
39790
+ eventKey: z38.string(),
39791
+ /** The conversion name the company counts it under, or null when nobody has named it. */
39792
+ name: z38.string().nullable(),
39793
+ /** Distinct visitors to the page who went on to do this. */
39794
+ convertingVisitors: z38.number()
39795
+ });
39796
+ var experimentHorizonSchema = z38.object({
39797
+ days: z38.number(),
39798
+ /** The smallest relative lift detectable in that many days. `null` when none would be. */
39799
+ detectableLift: z38.number().nullable()
39800
+ });
39084
39801
  var experimentPlanResponseSchema = z38.object({
39085
39802
  canRun: z38.boolean(),
39086
39803
  /** Present when `canRun` is false. Product language, ready to show. */
39087
39804
  reason: z38.string().optional(),
39805
+ /** The event the test would be read on. Absent when none could be chosen. */
39806
+ goal: experimentGoalSchema.optional(),
39807
+ /** The new version's own event, when one was given. */
39808
+ variantGoal: experimentGoalSchema.optional(),
39809
+ /** Every event this page's visitors went on to do over the sizing window, most common first. */
39810
+ conversions: z38.array(experimentPageConversionSchema),
39811
+ /** Distinct visitors the page had over the sizing window. */
39812
+ visitors: z38.number(),
39088
39813
  baselineRate: z38.number(),
39089
39814
  minDetectableRelativeLift: z38.number(),
39090
39815
  /** `null` when no amount of traffic could settle the hypothesis. */
@@ -39093,14 +39818,24 @@ var experimentPlanResponseSchema = z38.object({
39093
39818
  dailyVisitorsPerVariant: z38.number(),
39094
39819
  /** `null` when the page has no traffic to project from. */
39095
39820
  estimatedDays: z38.number().nullable(),
39096
- goal: experimentGoalSchema,
39097
- goalLabel: z38.string(),
39821
+ horizons: z38.array(experimentHorizonSchema),
39822
+ traffic: experimentTrafficSchema,
39823
+ baselineSource: experimentBaselineSourceSchema,
39824
+ sizedOn: experimentSizedOnSchema,
39825
+ /**
39826
+ * Why the sizing is a guide rather than a measurement, when it is one.
39827
+ * Product language, ready to show; absent on a page with real traffic that
39828
+ * has produced the goal.
39829
+ */
39830
+ warning: z38.string().optional(),
39098
39831
  /**
39099
- * The smallest lift `days` of this page's traffic could detect, when `days`
39100
- * was asked for. `null` when it was not, or when no lift at all would be
39101
- * detectable in that window.
39832
+ * Every finished test on this page, newest first see
39833
+ * `experimentHistoryEntrySchema`. On the plan because the plan is the moment
39834
+ * a hypothesis is chosen, and a page's own record is the first thing that
39835
+ * should inform it: a refuted idea proposed again, or a supported one never
39836
+ * built on, is what this exists to prevent.
39102
39837
  */
39103
- detectableLiftInDays: z38.number().nullable().optional()
39838
+ pastTests: z38.array(z38.lazy(() => experimentHistoryEntrySchema)).default([])
39104
39839
  });
39105
39840
  var experimentVerdictSchema = z38.enum([
39106
39841
  "keep_running",
@@ -39109,16 +39844,17 @@ var experimentVerdictSchema = z38.enum([
39109
39844
  "invalid",
39110
39845
  "stopped_early_harmful",
39111
39846
  /**
39112
- * Stopped by a person before it concluded. Never produced by the stopping
39113
- * rule — only by `finish --abandon`.
39847
+ * Ended before the numbers could say anything. Never produced by the
39848
+ * stopping rule — only by ending a `keep_running` test.
39114
39849
  *
39115
39850
  * Its own verdict rather than folded into `no_difference`, because they are
39116
39851
  * opposite claims: one says the two versions were measured and found alike,
39117
39852
  * the other says nobody ever found out. Collapsing them would manufacture the
39118
39853
  * exact finding this feature exists to refuse.
39119
39854
  */
39120
- "abandoned"
39855
+ "inconclusive"
39121
39856
  ]);
39857
+ var experimentStatusValueSchema = z38.enum(["running", "paused", "finished"]);
39122
39858
  var experimentArmSchema = z38.object({
39123
39859
  slug: z38.string(),
39124
39860
  visitors: z38.number(),
@@ -39132,7 +39868,28 @@ var experimentArmSchema = z38.object({
39132
39868
  * screens cannot show a client two different "this is your offer page".
39133
39869
  * `null` before the first capture, and on a page whose capture failed.
39134
39870
  */
39135
- previewUrl: z38.string().nullable().optional()
39871
+ previewUrl: z38.string().nullable().optional(),
39872
+ /**
39873
+ * This version as it was when the test started — the test's own capture,
39874
+ * taken off the live site once the split was being served.
39875
+ *
39876
+ * `previewUrl` above is the page as it is *today*, which is right while a
39877
+ * test runs and wrong the day after somebody edits the page, and absent
39878
+ * altogether for a variant that lost and was archived. This is the record;
39879
+ * a card prefers it and says which of the two it is showing.
39880
+ */
39881
+ snapshotUrl: z38.string().nullable().optional(),
39882
+ snapshotCapturedAt: z38.number().nullable().optional()
39883
+ });
39884
+ var experimentMetricSchema = z38.object({
39885
+ name: z38.string(),
39886
+ control: z38.object({ conversions: z38.number(), rate: z38.number() }),
39887
+ variant: z38.object({ conversions: z38.number(), rate: z38.number() }),
39888
+ relativeLift: z38.number(),
39889
+ /** The anytime-valid interval on the relative lift. */
39890
+ liftInterval: z38.tuple([z38.number(), z38.number()]),
39891
+ /** `better` when the interval sits above zero, `worse` below it, `unclear` across it. */
39892
+ reading: z38.enum(["better", "worse", "unclear"])
39136
39893
  });
39137
39894
  var experimentTimelineArmSchema = z38.object({
39138
39895
  visitors: z38.number(),
@@ -39173,19 +39930,51 @@ var experimentStatusRowSchema = z38.object({
39173
39930
  * is not a hypothesis, "leads should go up by at least 20%" is.
39174
39931
  */
39175
39932
  minDetectableRelativeLift: z38.number(),
39933
+ /** The event the verdict is read from on the page, by key. */
39934
+ goal: experimentGoalSchema,
39935
+ /** The event the new version is read on, when it differs from `goal`. */
39936
+ variantGoal: experimentGoalSchema.nullable(),
39937
+ /** Both, in words a marketer reads — the Form's name, the moment. */
39176
39938
  goalLabel: z38.string(),
39177
- status: z38.enum(["running", "finished"]),
39939
+ status: experimentStatusValueSchema,
39178
39940
  startedAt: z38.number(),
39941
+ /** Set while paused. The split is off and everyone sees the original. */
39942
+ pausedAt: z38.number().nullable(),
39179
39943
  finishedAt: z38.number().nullable(),
39180
39944
  verdict: experimentVerdictSchema,
39181
- /** Set only on `winner`. Which page to keep. */
39945
+ /** Set only on `winner`. Which version the numbers favour. */
39182
39946
  winner: z38.enum(["control", "variant"]).nullable(),
39947
+ /**
39948
+ * Which version is live, once the test has ended. Chosen by whoever ended it,
39949
+ * and allowed to disagree with `winner` — the card says so when it does.
39950
+ */
39951
+ survivor: z38.enum(["control", "variant"]).nullable(),
39183
39952
  /** Set only on `invalid`. Why the numbers cannot be read. */
39184
39953
  invalidReason: z38.enum(["sample_ratio_mismatch", "crossover"]).nullable(),
39954
+ /**
39955
+ * Where the page's own content stands after the test, once it has ended.
39956
+ *
39957
+ * `owed` — the new variant was kept and is served by a rewrite; the page's
39958
+ * source still says otherwise. `landing` — the files are written and the
39959
+ * next publish carries them. `done` — the page's own content is the version
39960
+ * that was kept, and nothing routes either arm any more. `none` — the
39961
+ * original was kept, so there was never anything to move.
39962
+ *
39963
+ * On the row because two things on the card read it: whether "open this
39964
+ * version" links still show the right file, and whether there is anything
39965
+ * left for a person to do.
39966
+ */
39967
+ fold: z38.enum(["none", "owed", "landing", "done"]).nullable(),
39185
39968
  /** What a person should be told, in one sentence, in product language. */
39186
39969
  summary: z38.string(),
39970
+ /** What the test taught, once somebody wrote it down. Null until then, and always null while it runs. */
39971
+ learning: z38.string().nullable(),
39972
+ /** Why it was ended, when whoever ended it said. Null while it runs. */
39973
+ endReason: experimentEndReasonSchema.nullable(),
39187
39974
  control: experimentArmSchema,
39188
39975
  variant: experimentArmSchema,
39976
+ /** Every other conversion the company counts, on the same two arms. */
39977
+ secondary: z38.array(experimentMetricSchema),
39189
39978
  /**
39190
39979
  * How close this test is to being able to say anything at all, 0 to 1.
39191
39980
  *
@@ -39199,8 +39988,8 @@ var experimentStatusRowSchema = z38.object({
39199
39988
  earliestDecisionAt: z38.number().nullable(),
39200
39989
  /**
39201
39990
  * True when no amount of this page's traffic would settle the comparison.
39202
- * A different thing from a page that is merely slow, and the reason to
39203
- * abandon a test rather than leave it running.
39991
+ * A different thing from a page that is merely slow, and the reason to end
39992
+ * a test rather than leave it running.
39204
39993
  */
39205
39994
  willNotConclude: z38.boolean(),
39206
39995
  /**
@@ -39216,6 +40005,13 @@ var experimentStatusRowSchema = z38.object({
39216
40005
  evidence: z38.object({
39217
40006
  relativeLift: z38.number(),
39218
40007
  probabilityVariantBetter: z38.number(),
40008
+ /**
40009
+ * How sure the numbers are that the two versions differ at all, 0 to 1.
40010
+ * Anytime-valid — the same construction the verdict is read from, so it
40011
+ * holds however often it is read — and a winner is called exactly when
40012
+ * it crosses 95%. The figure a card leads with.
40013
+ */
40014
+ confidence: z38.number(),
39219
40015
  controlInterval: z38.tuple([z38.number(), z38.number()]),
39220
40016
  variantInterval: z38.tuple([z38.number(), z38.number()]),
39221
40017
  /** The anytime-valid interval on the relative lift — what the verdict is read from. */
@@ -39225,6 +40021,54 @@ var experimentStatusRowSchema = z38.object({
39225
40021
  var experimentStatusResponseSchema = z38.object({
39226
40022
  experiments: z38.array(experimentStatusRowSchema)
39227
40023
  });
40024
+ var experimentHistoryEntrySchema = z38.object({
40025
+ experimentId: z38.string(),
40026
+ landingSlug: z38.string(),
40027
+ variantSlug: z38.string(),
40028
+ startedAt: z38.number(),
40029
+ finishedAt: z38.number(),
40030
+ /** Days the split ran, whole. */
40031
+ ranForDays: z38.number(),
40032
+ hypothesis: experimentHypothesisSchema,
40033
+ hypothesisStatement: z38.string(),
40034
+ /** The sections the new version owned, as labels — what was actually different. */
40035
+ changed: z38.array(z38.string()),
40036
+ goal: experimentGoalSchema,
40037
+ variantGoal: experimentGoalSchema.nullable(),
40038
+ goalLabel: z38.string(),
40039
+ verdict: experimentVerdictSchema,
40040
+ /** Which version the numbers favoured, on `winner`. */
40041
+ winner: z38.enum(["control", "variant"]).nullable(),
40042
+ /** Which version kept the traffic. */
40043
+ survivor: z38.enum(["control", "variant"]),
40044
+ /** True when what was kept is not what the numbers favoured. */
40045
+ againstVerdict: z38.boolean(),
40046
+ hypothesisOutcome: experimentHypothesisOutcomeSchema,
40047
+ /** The numbers, as they were when the test ended. */
40048
+ result: z38.object({
40049
+ control: z38.object({ visitors: z38.number(), conversions: z38.number(), rate: z38.number() }),
40050
+ variant: z38.object({ visitors: z38.number(), conversions: z38.number(), rate: z38.number() }),
40051
+ relativeLift: z38.number(),
40052
+ liftInterval: z38.tuple([z38.number(), z38.number()]),
40053
+ confidence: z38.number(),
40054
+ secondary: z38.array(experimentMetricSchema)
40055
+ }),
40056
+ summary: z38.string(),
40057
+ /** Why it was ended, when whoever ended it said. */
40058
+ endReason: experimentEndReasonSchema.nullable(),
40059
+ /** What the test taught, or null when nobody has written it down yet. */
40060
+ learning: z38.string().nullable()
40061
+ });
40062
+ var experimentHistoryResponseSchema = z38.object({
40063
+ tests: z38.array(experimentHistoryEntrySchema)
40064
+ });
40065
+ var experimentFinishResponseSchema = z38.object({
40066
+ verdict: experimentVerdictSchema,
40067
+ survivor: z38.enum(["control", "variant"]),
40068
+ /** True when `keep` went against a verdict the numbers had reached. */
40069
+ againstVerdict: z38.boolean(),
40070
+ summary: z38.string()
40071
+ });
39228
40072
 
39229
40073
  // src/commands/experiment/hypothesis.ts
39230
40074
  var SOURCES = experimentEvidenceSourceSchema.options.join(", ");
@@ -39246,9 +40090,8 @@ function parseOne(raw) {
39246
40090
  }
39247
40091
  return href ? { source: source.data, note, href } : { source: source.data, note };
39248
40092
  }
39249
- function parseEvidence(raw) {
39250
- if (raw === void 0) return void 0;
39251
- const entries = (Array.isArray(raw) ? raw : [raw]).filter((entry) => entry !== "");
40093
+ function parseEvidence(raw, rawArgs) {
40094
+ const entries = repeatedValues(rawArgs, "evidence", raw).map((entry) => entry.trim()).filter((entry) => entry !== "");
39252
40095
  if (entries.length === 0) return void 0;
39253
40096
  const parsed = [];
39254
40097
  for (const entry of entries) {
@@ -39259,20 +40102,70 @@ function parseEvidence(raw) {
39259
40102
  return parsed;
39260
40103
  }
39261
40104
 
40105
+ // src/commands/experiment/identity.ts
40106
+ import { readFile as readFile23 } from "fs/promises";
40107
+ import path30 from "path";
40108
+ async function readPageInternalId(root, slug) {
40109
+ try {
40110
+ const text2 = await readFile23(path30.resolve(root, "src", "pages", slug, "_definition.md"), "utf8");
40111
+ return /^internalId:\s*"?([^"\s]+)"?\s*$/m.exec(text2)?.[1];
40112
+ } catch {
40113
+ return void 0;
40114
+ }
40115
+ }
40116
+
40117
+ // src/commands/experiment/update.ts
40118
+ function buildUpdateRequest(args, rawArgs) {
40119
+ const expect = args.expect === void 0 ? void 0 : String(args.expect);
40120
+ if (expect !== void 0 && expect !== "increase" && expect !== "decrease") {
40121
+ return {
40122
+ error: "`--expect` is either `increase` or `decrease` \u2014 which way should the goal move if you are right?"
40123
+ };
40124
+ }
40125
+ const evidence = parseEvidence(args.evidence, rawArgs);
40126
+ if (evidence && "error" in evidence) return evidence;
40127
+ const lift = args.lift === void 0 ? void 0 : Number(args.lift);
40128
+ if (lift !== void 0 && !(lift > 0)) {
40129
+ return {
40130
+ error: "`--lift` is the smallest improvement worth detecting, relative \u2014 0.2 is +20%. It must be above zero."
40131
+ };
40132
+ }
40133
+ const request = {
40134
+ experimentId: String(args.id),
40135
+ ...typed("because", args.because),
40136
+ ...typed("change", args.change),
40137
+ ...typed("goal", args.goal),
40138
+ ...typed("variantGoal", args.variantGoal),
40139
+ ...typed("learning", args.learning),
40140
+ ...expect === void 0 ? {} : { expect },
40141
+ ...evidence ? { evidence } : {},
40142
+ ...lift === void 0 ? {} : { minDetectableRelativeLift: lift }
40143
+ };
40144
+ if (Object.keys(request).length === 1) {
40145
+ return {
40146
+ error: "Nothing to change. Pass --because, --change or --evidence; on a finished test, --learning; on a test that has not been published yet, also --expect, --goal, --variant-goal or --lift"
40147
+ };
40148
+ }
40149
+ return request;
40150
+ }
40151
+ function typed(key, value) {
40152
+ return value === void 0 ? {} : { [key]: String(value) };
40153
+ }
40154
+
39262
40155
  // src/commands/experiment/index.ts
39263
40156
  var GOAL_ARG = {
39264
40157
  type: "string",
39265
- description: "What counts as success, and it is fixed for the life of the test. `leads` (default \u2014 whatever this company already marks as a conversion), `leads:<form>`, `event:<name>` for an event the page declares itself (see `baker analytics events`), `event:<name>/<key>=<value>` to narrow it, `click` or `click:<host>` for leaving the site. Pick the thing the business actually wants; a test that measures the wrong outcome can have the variant win and the revenue fall.",
40158
+ description: "The ONE event the test is read on, by its key: `form:<flow>:<node>:<trigger>` (a Form reaching a step), `submit:<flow>` (a Form finished), `page:<name>` (a page event or one the client's systems post), `exit:<host>` (a click out). `baker analytics conversions --candidates` lists the keys these pages produce, and `plan` lists the ones this page's visitors went on to do. Fixed for the life of the test. Optional when exactly one of them is a named conversion; otherwise say which. Every conversion the company has named is reported beside the goal and decides nothing.",
39266
40159
  required: false
39267
40160
  };
39268
- var LIFT_ARG = {
40161
+ var VARIANT_GOAL_ARG = {
39269
40162
  type: "string",
39270
- description: "The smallest improvement worth detecting, relative \u2014 0.2 is +20% (default). The single most consequential number here: halving it roughly quadruples the traffic needed. Ask for the smallest lift that would actually change what the client does, not the smallest one you would like to see.",
40163
+ description: "The event the NEW version is read on, when it is not the page's \u2014 a variant that swaps the Form for a shorter one converts on the new Form's ending (`submit:<new-flow>`, or its confirmation step's `form:` key). Same vocabulary as --goal. Leave it out when both versions convert on the same event, which is the common case.",
39271
40164
  required: false
39272
40165
  };
39273
- var DAYS_ARG = {
40166
+ var LIFT_ARG = {
39274
40167
  type: "string",
39275
- description: "How many days you have, if that is the constraint. This does NOT shorten the test \u2014 the finish line is a visitor count fixed before it starts \u2014 it answers the other question: what a window that long could settle on this page's traffic. Use it when `plan` refuses and you need to know what WOULD fit.",
40168
+ description: "The smallest improvement worth detecting, relative \u2014 0.2 is +20% (default). The single most consequential number here: halving it roughly quadruples the traffic needed. Ask for the smallest lift that would actually change what the client does, not the smallest one you would like to see.",
39276
40169
  required: false
39277
40170
  };
39278
40171
  var BECAUSE_ARG = {
@@ -39295,6 +40188,30 @@ var EVIDENCE_ARG = {
39295
40188
  description: "Where the observation came from, as `source:what it showed`, optionally `|link`. Repeatable. Sources: baker_analytics, clarity, ads, search_console, research, inspiration, client, hunch. Use `hunch` honestly when there is no data \u2014 a stated guess is worth more than a guess dressed as analytics.",
39296
40189
  required: false
39297
40190
  };
40191
+ var BASELINE_ARG = {
40192
+ type: "string",
40193
+ description: "The share of visitors you expect to do the goal, as a fraction \u2014 0.05 is 5% \u2014 for a page whose history cannot say: a new page, or one about to get its first campaign. Without it a page with no conversions yet is sized on the company-wide rate for the outcome, or on 3%. The sizing is a guide either way: the test runs until the numbers are clear.",
40194
+ required: false
40195
+ };
40196
+ var ID_ARG = { type: "string", description: "The test, by its id", required: true };
40197
+ var LEARNING_ARG = {
40198
+ type: "string",
40199
+ description: "What this test taught, in one or two sentences the client should believe about this page from now on \u2014 \u201Cthe price is not the objection, the install date is\u201D. The one part of the record the numbers cannot write, and what `plan` hands the next test on this page. Finished tests only.",
40200
+ required: false
40201
+ };
40202
+ var REASON_ARG = {
40203
+ type: "string",
40204
+ description: "Why the test is being ended, as distinct from what the numbers said: `numbers_clear` (the result was clear enough to act on), `not_settling` (too little traffic or too small a difference \u2014 waiting would not help), `page_changing` (the page has to change for another reason), `test_flawed` (the wrong goal, a broken variant). Always say \u2014 the dashboard asks a person the same question, and the next test on this page is planned differently after each answer.",
40205
+ required: false
40206
+ };
40207
+ var LANDING_FILTER_ARG = {
40208
+ type: "string",
40209
+ description: "Only the tests on one page, by its slug",
40210
+ required: false
40211
+ };
40212
+ function sizingHints(data) {
40213
+ return typeof data.warning === "string" ? [data.warning] : [];
40214
+ }
39298
40215
  function fail5(message) {
39299
40216
  writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message } });
39300
40217
  process.exit(1);
@@ -39308,43 +40225,40 @@ function reportApiError(error) {
39308
40225
  }
39309
40226
  registerSchema({
39310
40227
  command: "experiment.plan",
39311
- description: "Whether a page has enough traffic to settle a question, before anything is built",
40228
+ description: "Whether a page can settle a question, and how long it might take, before anything is built",
39312
40229
  args: {
39313
40230
  landing: { type: "string", description: "The page under test, by slug", required: true },
39314
- variant: { type: "string", description: "The alternative page's slug", required: true },
40231
+ variant: { type: "string", description: "The new variant's slug, `<page>--<n>`", required: true },
39315
40232
  goal: GOAL_ARG,
40233
+ variantGoal: VARIANT_GOAL_ARG,
39316
40234
  lift: LIFT_ARG,
39317
- days: DAYS_ARG
40235
+ baseline: BASELINE_ARG
39318
40236
  }
39319
40237
  });
39320
40238
  var planCommand = defineCommand119({
39321
40239
  meta: {
39322
40240
  name: "plan",
39323
- description: "Start here, BEFORE building the alternative page. Says whether this page gets enough traffic to settle the question, how long it would take, and whether the thing you want to measure has ever happened on it. Most landing pages cannot run most tests \u2014 finding that out now costs one call, and finding it out later costs three weeks and a decision made on noise."
40241
+ description: "Start here, BEFORE building the alternative page. Says which event the test would be read on, which events the page's visitors actually go on to do (by key, with the conversion name each is counted under), how long it might take at this page's traffic, and what a 14, 28, 56 or 90-day window could settle. The duration is a guide, never a finish line \u2014 a test ends when the numbers are clear, and a page with no traffic yet can still be tested (`warning` says what it was sized on). What IS refused is a test that could not be measured: a goal this page has demonstrably never produced."
39324
40242
  },
39325
40243
  args: {
39326
40244
  landing: { type: "string", description: "The page under test, by slug", required: true },
39327
- variant: { type: "string", description: "The alternative page's slug", required: true },
40245
+ variant: { type: "string", description: "The new variant's slug, `<page>--<n>`", required: true },
39328
40246
  goal: GOAL_ARG,
40247
+ variantGoal: VARIANT_GOAL_ARG,
39329
40248
  lift: LIFT_ARG,
39330
- days: DAYS_ARG
40249
+ baseline: BASELINE_ARG
39331
40250
  },
39332
40251
  run: async ({ args }) => {
39333
- const goal = parseGoal(args.goal ? String(args.goal) : void 0);
39334
- if (goal && "error" in goal) fail5(goal.error);
39335
40252
  try {
39336
40253
  const response = await apiPost("/api/experiments/plan", {
39337
40254
  landingSlug: String(args.landing),
39338
40255
  variantSlug: String(args.variant),
39339
- ...goal ? { goal } : {},
40256
+ ...args.goal ? { goal: String(args.goal) } : {},
40257
+ ...args.variantGoal ? { variantGoal: String(args.variantGoal) } : {},
39340
40258
  ...args.lift ? { minDetectableRelativeLift: Number(args.lift) } : {},
39341
- ...args.days ? { days: Number(args.days) } : {}
39342
- });
39343
- writeJsonEnvelope({
39344
- ok: true,
39345
- data: response.data,
39346
- hints: buildPlanHints(response.data, args.days ? Number(args.days) : void 0)
40259
+ ...args.baseline ? { baselineRate: Number(args.baseline) } : {}
39347
40260
  });
40261
+ writeJsonEnvelope({ ok: true, data: response.data, hints: buildPlanHints(response.data) });
39348
40262
  } catch (error) {
39349
40263
  reportApiError(error);
39350
40264
  }
@@ -39352,47 +40266,60 @@ var planCommand = defineCommand119({
39352
40266
  });
39353
40267
  registerSchema({
39354
40268
  command: "experiment.start",
39355
- description: "Stage an A/B test between a page and its alternative",
40269
+ description: "Stage an A/B test between a page and a new variant of it",
39356
40270
  args: {
39357
40271
  landing: { type: "string", description: "The page under test, by slug", required: true },
39358
- variant: { type: "string", description: "The alternative page's slug", required: true },
40272
+ variant: { type: "string", description: "The new variant's slug, `<page>--<n>`", required: true },
39359
40273
  because: BECAUSE_ARG,
39360
40274
  change: CHANGE_ARG,
39361
40275
  expect: EXPECT_ARG,
39362
40276
  evidence: EVIDENCE_ARG,
39363
40277
  goal: GOAL_ARG,
39364
- lift: LIFT_ARG
40278
+ variantGoal: VARIANT_GOAL_ARG,
40279
+ lift: LIFT_ARG,
40280
+ baseline: BASELINE_ARG
39365
40281
  }
39366
40282
  });
39367
40283
  var startCommand = defineCommand119({
39368
40284
  meta: {
39369
40285
  name: "start",
39370
- description: "Stage a test between the page and its alternative. Both are ordinary published pages; visitors are split 50/50 at the edge, so there is no flicker and nothing for an ad blocker to suppress. Nothing goes live until the session is published \u2014 the person publishing is the review of the alternative page. Re-runs `plan` first and refuses on the same grounds."
40286
+ description: "Stage a test between the page and its new variant. Visitors are split between the two versions at the edge, so there is no flicker and nothing for an ad blocker to suppress. Nothing goes live until the session is published \u2014 the person publishing is the review of the new variant. Re-runs `plan` first and refuses on the same grounds."
39371
40287
  },
39372
40288
  args: {
39373
40289
  landing: { type: "string", description: "The page under test, by slug", required: true },
39374
- variant: { type: "string", description: "The alternative page's slug", required: true },
40290
+ variant: { type: "string", description: "The new variant's slug, `<page>--<n>`", required: true },
39375
40291
  because: BECAUSE_ARG,
39376
40292
  change: CHANGE_ARG,
39377
40293
  expect: EXPECT_ARG,
39378
40294
  evidence: EVIDENCE_ARG,
39379
40295
  goal: GOAL_ARG,
39380
- lift: LIFT_ARG
40296
+ variantGoal: VARIANT_GOAL_ARG,
40297
+ lift: LIFT_ARG,
40298
+ baseline: BASELINE_ARG
39381
40299
  },
39382
- run: async ({ args }) => {
39383
- const goal = parseGoal(args.goal ? String(args.goal) : void 0);
39384
- if (goal && "error" in goal) fail5(goal.error);
40300
+ run: async ({ args, rawArgs }) => {
39385
40301
  const expect = args.expect === void 0 ? "increase" : String(args.expect);
39386
40302
  if (expect !== "increase" && expect !== "decrease") {
39387
40303
  fail5("`--expect` is either `increase` or `decrease` \u2014 which way should the goal move if you are right?");
39388
40304
  }
39389
- const evidence = parseEvidence(args.evidence);
40305
+ const evidence = parseEvidence(args.evidence, rawArgs);
39390
40306
  if (evidence && "error" in evidence) fail5(evidence.error);
39391
40307
  const composition = await readComposition(process.cwd(), String(args.landing), String(args.variant));
40308
+ const [controlForms, variantForms] = await Promise.all([
40309
+ readForms(process.cwd(), String(args.landing)),
40310
+ readForms(process.cwd(), String(args.variant))
40311
+ ]);
40312
+ const newForms = formsOnlyIn(variantForms, controlForms);
40313
+ const [landingInternalId, variantInternalId] = await Promise.all([
40314
+ readPageInternalId(process.cwd(), String(args.landing)),
40315
+ readPageInternalId(process.cwd(), String(args.variant))
40316
+ ]);
39392
40317
  try {
39393
40318
  const response = await apiPost("/api/experiments/start", {
39394
40319
  landingSlug: String(args.landing),
39395
40320
  variantSlug: String(args.variant),
40321
+ ...landingInternalId ? { landingInternalId } : {},
40322
+ ...variantInternalId ? { variantInternalId } : {},
39396
40323
  hypothesis: {
39397
40324
  because: String(args.because),
39398
40325
  change: String(args.change),
@@ -39400,20 +40327,25 @@ var startCommand = defineCommand119({
39400
40327
  ...evidence ? { evidence } : {}
39401
40328
  },
39402
40329
  ...composition ? { composition } : {},
39403
- ...goal ? { goal } : {},
39404
- ...args.lift ? { minDetectableRelativeLift: Number(args.lift) } : {}
40330
+ ...args.goal ? { goal: String(args.goal) } : {},
40331
+ ...args.variantGoal ? { variantGoal: String(args.variantGoal) } : {},
40332
+ ...args.lift ? { minDetectableRelativeLift: Number(args.lift) } : {},
40333
+ ...args.baseline ? { baselineRate: Number(args.baseline) } : {}
39405
40334
  });
39406
40335
  writeJsonEnvelope({
39407
40336
  ok: true,
39408
40337
  data: response.data,
39409
40338
  hints: [
39410
- // The one composition worth interrupting for: `baker landing variant`
39411
- // with no `--fork` succeeds and produces a page that renders exactly
39412
- // the control, so the test would run for weeks against itself.
39413
- ...composition && composition.forked.length === 0 ? [
39414
- `\u201C${String(args.variant)}\u201D does not have its own copy of any section \u2014 it renders exactly the same page as \u201C${String(args.landing)}\u201D, so this test cannot find anything. Fork the section you want to test (\`baker landing variant ${String(args.landing)} ${String(args.variant)} --fork Hero.astro\`) and edit only that file.`
39415
- ] : [],
39416
- "Staged. The split starts when this session is published \u2014 publishing is the review of the alternative page.",
40339
+ ...sizingHints(response.data),
40340
+ ...buildStartHints({
40341
+ landing: String(args.landing),
40342
+ variant: String(args.variant),
40343
+ goal: String(response.data.goal),
40344
+ variantGoal: args.variantGoal === void 0 ? void 0 : String(args.variantGoal),
40345
+ composition,
40346
+ newForms
40347
+ }),
40348
+ "Staged. The split starts when this session is published \u2014 publishing is the review of the new variant.",
39417
40349
  "Check it as often as you like \u2014 `baker experiment status` is safe to read at any moment and holds its error rate however often you ask. What is never safe is reading the numbers instead of the verdict: `keep_running` means this test cannot yet tell the two pages apart, whatever the rates happen to say today."
39418
40350
  ]
39419
40351
  });
@@ -39424,25 +40356,28 @@ var startCommand = defineCommand119({
39424
40356
  });
39425
40357
  registerSchema({
39426
40358
  command: "experiment.status",
39427
- description: "The verdict on every A/B test, running and finished",
40359
+ description: "The verdict on every A/B test, running, paused and finished",
39428
40360
  args: {
39429
40361
  id: { type: "string", description: "One test, by its id", required: false },
40362
+ landing: LANDING_FILTER_ARG,
39430
40363
  full: { type: "boolean", description: "Include the posteriors behind the verdict", required: false }
39431
40364
  }
39432
40365
  });
39433
40366
  var statusCommand6 = defineCommand119({
39434
40367
  meta: {
39435
40368
  name: "status",
39436
- description: "The verdict on each test. Read `verdict` and nothing else to decide: keep_running | winner | no_difference | invalid | stopped_early_harmful. `summary` is one sentence you can show the client. The numbers under `--full` are for printing, never for deciding \u2014 a test that says keep_running has not finished, however good its numbers look."
40369
+ description: "The verdict on each test. Read `verdict` and nothing else to decide: keep_running | winner | no_difference | invalid | stopped_early_harmful. `summary` is one sentence you can show the client, and `secondary` lists every other conversion the company counts on the same two arms \u2014 context, never a second decision. The numbers under `--full` are for printing, never for deciding \u2014 a test that says keep_running has not finished, however good its numbers look."
39437
40370
  },
39438
40371
  args: {
39439
40372
  id: { type: "string", description: "One test, by its id", required: false },
40373
+ landing: LANDING_FILTER_ARG,
39440
40374
  full: { type: "boolean", description: "Include the posteriors behind the verdict", required: false }
39441
40375
  },
39442
40376
  run: async ({ args }) => {
39443
40377
  try {
39444
40378
  const response = await apiPost("/api/experiments/status", {
39445
40379
  ...args.id ? { experimentId: String(args.id) } : {},
40380
+ ...args.landing ? { landingSlug: String(args.landing) } : {},
39446
40381
  ...args.full === true ? { full: true } : {}
39447
40382
  });
39448
40383
  writeJsonEnvelope({
@@ -39456,45 +40391,218 @@ var statusCommand6 = defineCommand119({
39456
40391
  }
39457
40392
  });
39458
40393
  registerSchema({
39459
- command: "experiment.finish",
39460
- description: "End a test and send all its traffic to the surviving page",
40394
+ command: "experiment.history",
40395
+ description: "Every finished A/B test with what it found and what it taught \u2014 the record to read before proposing the next one",
39461
40396
  args: {
39462
- id: { type: "string", description: "The test, by its id", required: true },
39463
- abandon: {
39464
- type: "boolean",
39465
- description: "Stop the test, keep the original page, and record no result. The only way to end a test that has not concluded \u2014 and it OVERRIDES a verdict if there is one, throwing that finding away. Do not pass it by reflex.",
39466
- required: false
40397
+ landing: LANDING_FILTER_ARG,
40398
+ limit: { type: "string", description: "At most this many, newest first (default all)", required: false }
40399
+ }
40400
+ });
40401
+ var historyCommand = defineCommand119({
40402
+ meta: {
40403
+ name: "history",
40404
+ description: "What was tested before and what it taught. Finished tests only, newest first: the claim, what was different, what the numbers said, what was kept and whether that went against them, and the `learning` somebody wrote down. Read it \u2014 `--landing <slug>` for one page \u2014 BEFORE proposing a test: a refuted idea proposed again spends the page's traffic to return the same answer, and a supported one is what the next test should build on. `plan` hands the same entries back as `pastTests` for the page it sizes."
40405
+ },
40406
+ args: {
40407
+ landing: LANDING_FILTER_ARG,
40408
+ limit: { type: "string", description: "At most this many, newest first", required: false }
40409
+ },
40410
+ run: async ({ args }) => {
40411
+ const limit = args.limit === void 0 ? void 0 : Number(args.limit);
40412
+ if (limit !== void 0 && !(Number.isInteger(limit) && limit > 0)) fail5("`--limit` is a whole number above zero");
40413
+ try {
40414
+ const response = await apiPost("/api/experiments/history", {
40415
+ ...args.landing ? { landingSlug: String(args.landing) } : {},
40416
+ ...limit === void 0 ? {} : { limit }
40417
+ });
40418
+ writeJsonEnvelope({
40419
+ ok: true,
40420
+ data: response.data,
40421
+ hints: buildHistoryHints(response.data.tests, args.landing === void 0 ? void 0 : String(args.landing))
40422
+ });
40423
+ } catch (error) {
40424
+ reportApiError(error);
39467
40425
  }
39468
40426
  }
39469
40427
  });
40428
+ registerSchema({
40429
+ command: "experiment.finish",
40430
+ description: "End a test now and send all its traffic to the version you name",
40431
+ args: {
40432
+ id: ID_ARG,
40433
+ keep: {
40434
+ type: "string",
40435
+ description: "Which version stays live: `original` or `variant`. Required \u2014 the verdict is a recommendation and this is the decision. A test that has not concluded can be ended too; it is recorded as inconclusive, never as a finding. Going against a verdict is allowed and is recorded as such.",
40436
+ required: true
40437
+ },
40438
+ reason: REASON_ARG,
40439
+ learning: LEARNING_ARG
40440
+ }
40441
+ });
40442
+ var END_REASONS = ["numbers_clear", "not_settling", "page_changing", "test_flawed"];
40443
+ function finishHints(data, given) {
40444
+ const { reason } = given;
40445
+ const args = { learning: given.learning };
40446
+ return [
40447
+ `Done. Everyone now sees the ${data.survivor === "variant" ? "new variant" : "original"}.`,
40448
+ ...data.againstVerdict ? [
40449
+ `You kept the ${data.survivor === "variant" ? "new variant" : "original"} although the numbers favoured the other version (${data.verdict}). That is recorded on the test \u2014 say why in the write-up.`
40450
+ ] : [],
40451
+ ...data.verdict === "keep_running" ? [
40452
+ "This test had not concluded, so it is recorded as inconclusive \u2014 nobody found out. Never report it as no difference."
40453
+ ] : [],
40454
+ ...data.survivor === "variant" ? [
40455
+ "Now run `baker experiment fold` \u2014 it moves the winning version into the page itself and archives the other one. Until that lands the page is served by a rewrite, and it cannot be tested again."
40456
+ ] : [],
40457
+ ...args.learning || data.verdict === "keep_running" ? [] : [
40458
+ 'Write down what this taught while it is fresh: `baker experiment update --id <id> --learning "\u2026"` \u2014 one or two sentences the client should believe about this page now. It is what the next test on this page starts from.'
40459
+ ],
40460
+ ...reason ? [] : [
40461
+ "No `--reason` was given, so the record says what the numbers had reached but not why anyone stopped looking. Next time pass one: numbers_clear, not_settling, page_changing or test_flawed."
40462
+ ]
40463
+ ];
40464
+ }
39470
40465
  var finishCommand = defineCommand119({
39471
40466
  meta: {
39472
40467
  name: "finish",
39473
- description: "End a test and send all its traffic to whichever page survived. Only a test whose verdict has concluded can be finished with a result \u2014 a `keep_running` test can only be `--abandon`ed, which keeps the original page and records no finding. Promotion is a traffic change over two pages that were both already published and reviewed; it does not edit either page."
40468
+ description: "End a test now and send all its traffic to the version you name. Takes effect within seconds \u2014 nothing to publish. Read `baker experiment status` first: `winner` says which version the numbers favour, `keep_running` means they have not spoken yet and ending records `inconclusive`. Keeping the variant owes a `fold`. A test staged in this session and not yet published is simply taken back off it."
39474
40469
  },
39475
40470
  args: {
39476
- id: { type: "string", description: "The test, by its id", required: true },
39477
- abandon: {
39478
- type: "boolean",
39479
- description: "Stop the test, keep the original page, and record no result. The only way to end a test that has not concluded \u2014 and it OVERRIDES a verdict if there is one, throwing that finding away. Do not pass it by reflex.",
39480
- required: false
40471
+ id: ID_ARG,
40472
+ keep: {
40473
+ type: "string",
40474
+ description: "Which version stays live: `original` or `variant`.",
40475
+ required: true
40476
+ },
40477
+ reason: REASON_ARG,
40478
+ learning: LEARNING_ARG
40479
+ },
40480
+ run: async ({ args }) => {
40481
+ const keep = String(args.keep);
40482
+ if (keep !== "original" && keep !== "variant") {
40483
+ fail5("`--keep` is either `original` or `variant` \u2014 which version should everyone see from now on?");
40484
+ }
40485
+ const reason = args.reason === void 0 ? void 0 : String(args.reason);
40486
+ if (reason !== void 0 && !END_REASONS.some((known) => known === reason)) {
40487
+ fail5(`\`--reason\` is one of ${END_REASONS.join(", ")} \u2014 why is this test being ended?`);
39481
40488
  }
40489
+ try {
40490
+ const response = await apiPost(
40491
+ "/api/experiments/finish",
40492
+ {
40493
+ experimentId: String(args.id),
40494
+ keep,
40495
+ ...reason ? { reason } : {},
40496
+ ...args.learning ? { learning: String(args.learning) } : {}
40497
+ }
40498
+ );
40499
+ if ("unstaged" in response.data) {
40500
+ writeJsonEnvelope({
40501
+ ok: true,
40502
+ data: response.data,
40503
+ hints: ["That test had not been published yet, so it was taken back off this session. Nothing was split."]
40504
+ });
40505
+ return;
40506
+ }
40507
+ const data = response.data;
40508
+ writeJsonEnvelope({ ok: true, data, hints: finishHints(data, { reason, learning: args.learning }) });
40509
+ } catch (error) {
40510
+ reportApiError(error);
40511
+ }
40512
+ }
40513
+ });
40514
+ registerSchema({
40515
+ command: "experiment.cancel",
40516
+ description: "Call a test off \u2014 take a staged one back off the session, or end a live one keeping the original",
40517
+ args: { id: ID_ARG }
40518
+ });
40519
+ var cancelCommand = defineCommand119({
40520
+ meta: {
40521
+ name: "cancel",
40522
+ description: "Call a test off. A test staged in this session and not yet published is simply taken back off it \u2014 nothing was split. A test that is live is ended now with everyone sent back to the original: what the numbers had said is recorded as it stood (usually `inconclusive`), and the variant page stays in the workspace, unserved. `finish --keep` is the same act with the choice of version; this is the shortcut for \u201Cstop, keep the page as it is\u201D."
39482
40523
  },
40524
+ args: { id: ID_ARG },
39483
40525
  run: async ({ args }) => {
39484
40526
  try {
39485
- const response = await apiPost("/api/experiments/finish", {
39486
- experimentId: String(args.id),
39487
- ...args.abandon === true ? { abandon: true } : {}
40527
+ const response = await apiPost(
40528
+ "/api/experiments/finish",
40529
+ { experimentId: String(args.id), keep: "original" }
40530
+ );
40531
+ if ("unstaged" in response.data) {
40532
+ writeJsonEnvelope({
40533
+ ok: true,
40534
+ data: response.data,
40535
+ hints: ["That test had not been published yet, so it was taken back off this session. Nothing was split."]
40536
+ });
40537
+ return;
40538
+ }
40539
+ const data = response.data;
40540
+ writeJsonEnvelope({
40541
+ ok: true,
40542
+ data,
40543
+ hints: [
40544
+ "Ended. Everyone sees the original page now, and the split stopped within seconds.",
40545
+ ...data.verdict === "keep_running" ? [
40546
+ "It had not concluded, so it is recorded as inconclusive \u2014 nobody found out. Never report it as no difference."
40547
+ ] : data.againstVerdict ? [
40548
+ `The numbers favoured the new variant (${data.verdict}), so this is recorded as a decision against them. Say why in the write-up.`
40549
+ ] : [],
40550
+ "The variant page is still in the workspace and served to nobody. Run `baker experiment fold` to archive it, or leave it for a later test."
40551
+ ]
39488
40552
  });
40553
+ } catch (error) {
40554
+ reportApiError(error);
40555
+ }
40556
+ }
40557
+ });
40558
+ registerSchema({
40559
+ command: "experiment.update",
40560
+ description: "Change what a test says about itself \u2014 its wording always, its measurement only while it is still staged",
40561
+ args: {
40562
+ id: ID_ARG,
40563
+ because: { ...BECAUSE_ARG, required: false },
40564
+ change: { ...CHANGE_ARG, required: false },
40565
+ evidence: EVIDENCE_ARG,
40566
+ expect: { ...EXPECT_ARG, description: "Staged tests only. `increase` or `decrease`." },
40567
+ goal: { ...GOAL_ARG, description: "Staged tests only. The event the test is read on, by key." },
40568
+ variantGoal: {
40569
+ ...VARIANT_GOAL_ARG,
40570
+ description: "Staged tests only. The new version's own event, when it differs."
40571
+ },
40572
+ lift: { ...LIFT_ARG, description: "Staged tests only. The smallest lift worth detecting." },
40573
+ learning: LEARNING_ARG
40574
+ }
40575
+ });
40576
+ var updateCommand3 = defineCommand119({
40577
+ meta: {
40578
+ name: "update",
40579
+ description: "Change what a test says about itself. The observation (`--because`), the change in the client's words (`--change`) and the evidence cited (`--evidence`) can change at any time \u2014 they describe the test, and a write-up corrected after the fact is still the write-up of the same test. On a FINISHED test, `--learning` writes down what it taught. What the test MEASURES \u2014 `--goal`, `--lift`, `--expect` \u2014 can only change while it is still staged in this session: once visitors have been split, moving the goal or the finish line is how a result gets argued into existence. To measure something else on a live test, `finish` it and start a new one."
40580
+ },
40581
+ args: {
40582
+ id: ID_ARG,
40583
+ because: { ...BECAUSE_ARG, required: false },
40584
+ change: { ...CHANGE_ARG, required: false },
40585
+ evidence: EVIDENCE_ARG,
40586
+ expect: { ...EXPECT_ARG, description: "Staged tests only. `increase` or `decrease`." },
40587
+ goal: { ...GOAL_ARG, description: "Staged tests only. The event the test is read on, by key." },
40588
+ variantGoal: {
40589
+ ...VARIANT_GOAL_ARG,
40590
+ description: "Staged tests only. The new version's own event, when it differs."
40591
+ },
40592
+ lift: { ...LIFT_ARG, description: "Staged tests only. The smallest lift worth detecting." },
40593
+ learning: LEARNING_ARG
40594
+ },
40595
+ run: async ({ args, rawArgs }) => {
40596
+ const request = buildUpdateRequest(args, rawArgs);
40597
+ if ("error" in request) fail5(request.error);
40598
+ try {
40599
+ const response = await apiPost("/api/experiments/update", request);
39489
40600
  writeJsonEnvelope({
39490
40601
  ok: true,
39491
40602
  data: response.data,
39492
40603
  hints: [
39493
- `Staged. All the traffic goes to the ${response.data.survivor === "variant" ? "alternative" : "original"} page when this session is published.`,
39494
- ...response.data.discardedVerdict ? [
39495
- `You abandoned a test that HAD concluded (${response.data.discardedVerdict}). The original page keeps the traffic and no result is recorded \u2014 the finding is gone. If that was not what you meant, run this again without --abandon.`
39496
- ] : [],
39497
- "Then run `baker experiment fold` \u2014 it moves the winning version into the page itself and archives the other one, so the page's own content is what visitors get. Until that lands the page is served by a rewrite, and it cannot be tested again."
40604
+ response.data.staged ? "Updated and re-sized. It still starts when this session is published." : request.learning !== void 0 ? "Updated. What this test taught is on its record now, and `plan` hands it to the next test on this page." : "Updated. The test keeps running exactly as it was \u2014 only its wording changed.",
40605
+ `It now reads: ${response.data.hypothesisStatement}`
39498
40606
  ]
39499
40607
  });
39500
40608
  } catch (error) {
@@ -39502,6 +40610,56 @@ var finishCommand = defineCommand119({
39502
40610
  }
39503
40611
  }
39504
40612
  });
40613
+ registerSchema({
40614
+ command: "experiment.pause",
40615
+ description: "Switch a test's split off \u2014 everyone sees the original until it is resumed",
40616
+ args: { id: ID_ARG }
40617
+ });
40618
+ var pauseCommand = defineCommand119({
40619
+ meta: {
40620
+ name: "pause",
40621
+ description: "Switch the split off now. Everyone sees the original; visitors already assigned keep their side, so resuming continues the same test rather than starting a new one. Use it when the new variant has to come down for a while \u2014 a broken price, a campaign that needs the original \u2014 without throwing the test away."
40622
+ },
40623
+ args: { id: ID_ARG },
40624
+ run: async ({ args }) => {
40625
+ try {
40626
+ await apiPost("/api/experiments/pause", { experimentId: String(args.id) });
40627
+ writeJsonEnvelope({
40628
+ ok: true,
40629
+ data: { paused: true },
40630
+ hints: [
40631
+ `Paused. Everyone sees the original page now. \`baker experiment resume --id ${String(args.id)}\` restarts the split with the same sides.`
40632
+ ]
40633
+ });
40634
+ } catch (error) {
40635
+ reportApiError(error);
40636
+ }
40637
+ }
40638
+ });
40639
+ registerSchema({
40640
+ command: "experiment.resume",
40641
+ description: "Switch a paused test's split back on",
40642
+ args: { id: ID_ARG }
40643
+ });
40644
+ var resumeCommand = defineCommand119({
40645
+ meta: {
40646
+ name: "resume",
40647
+ description: "Switch a paused test's split back on. Visitors already assigned see the same side they saw before."
40648
+ },
40649
+ args: { id: ID_ARG },
40650
+ run: async ({ args }) => {
40651
+ try {
40652
+ await apiPost("/api/experiments/resume", { experimentId: String(args.id) });
40653
+ writeJsonEnvelope({
40654
+ ok: true,
40655
+ data: { resumed: true },
40656
+ hints: ["Resumed. The split is back on, with the same sides."]
40657
+ });
40658
+ } catch (error) {
40659
+ reportApiError(error);
40660
+ }
40661
+ }
40662
+ });
39505
40663
  registerSchema({
39506
40664
  command: "experiment.fold",
39507
40665
  description: "Make the page's own content the version that won, and archive the one that lost",
@@ -39541,6 +40699,7 @@ var foldCommand = defineCommand119({
39541
40699
  if (folded.length > 0) {
39542
40700
  await apiPost("/api/experiments/fold", {
39543
40701
  applied: true,
40702
+ experimentIds: folded.map((result) => result.experimentId),
39544
40703
  ...args.id ? { experimentId: String(args.id) } : {}
39545
40704
  });
39546
40705
  }
@@ -39563,13 +40722,18 @@ var foldCommand = defineCommand119({
39563
40722
  var experimentCommand = defineCommand119({
39564
40723
  meta: {
39565
40724
  name: "experiment",
39566
- description: "Run an A/B test between a landing page and an alternative version of it, and be told which one is better. Always `plan` first: most pages do not get enough traffic to settle most questions, and a test that cannot conclude still produces a number somebody will act on."
40725
+ description: "Run an A/B test between a landing page and a new variant of it, and be told which one is better. Always `plan` first: most pages do not get enough traffic to settle most questions, and a test that cannot conclude still produces a number somebody will act on. Nothing ends a test on its own \u2014 `finish` does, when you or the client decide."
39567
40726
  },
39568
40727
  subCommands: {
39569
40728
  plan: planCommand,
39570
40729
  start: startCommand,
40730
+ update: updateCommand3,
39571
40731
  status: statusCommand6,
40732
+ history: historyCommand,
40733
+ pause: pauseCommand,
40734
+ resume: resumeCommand,
39572
40735
  finish: finishCommand,
40736
+ cancel: cancelCommand,
39573
40737
  fold: foldCommand
39574
40738
  }
39575
40739
  });
@@ -39580,176 +40744,6 @@ import { defineCommand as defineCommand124 } from "citty";
39580
40744
  // src/commands/flows/add.ts
39581
40745
  import { randomUUID } from "crypto";
39582
40746
  import { defineCommand as defineCommand120 } from "citty";
39583
-
39584
- // src/commands/flows/shared.ts
39585
- import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync11, writeFileSync as writeFileSync3 } from "fs";
39586
- import { join as join3 } from "path";
39587
- var FLOWS_DIR = "src/lib/flow-engine/flows";
39588
- function flowsDir() {
39589
- let dir = process.cwd();
39590
- for (let i = 0; i < 6; i++) {
39591
- const candidate = join3(dir, FLOWS_DIR);
39592
- if (existsSync5(candidate)) {
39593
- return candidate;
39594
- }
39595
- const parent = join3(dir, "..");
39596
- if (parent === dir) {
39597
- break;
39598
- }
39599
- dir = parent;
39600
- }
39601
- return join3(process.cwd(), FLOWS_DIR);
39602
- }
39603
- function failLocal(message) {
39604
- writeJson({ ok: false, error: { code: "NOT_FOUND", message } });
39605
- process.exit(1);
39606
- }
39607
- function listFlowSlugs() {
39608
- const dir = flowsDir();
39609
- if (!existsSync5(dir)) {
39610
- return [];
39611
- }
39612
- return readdirSync2(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith("_") && entry.name !== ".gitkeep").map((entry) => entry.name).sort();
39613
- }
39614
- function flowDataPath(slug) {
39615
- return join3(flowsDir(), slug, "_data.json");
39616
- }
39617
- function writeFlowTree(slug, tree) {
39618
- writeFileSync3(flowDataPath(slug), `${JSON.stringify(tree, null, 2)}
39619
- `, "utf-8");
39620
- }
39621
- function walkNodes(node, acc = []) {
39622
- if (!node || typeof node !== "object") return acc;
39623
- acc.push(node);
39624
- if (Array.isArray(node.children)) {
39625
- for (const child of node.children) walkNodes(child, acc);
39626
- }
39627
- return acc;
39628
- }
39629
- function collectSideEffects(tree) {
39630
- return walkNodes(tree).flatMap(
39631
- (node) => Array.isArray(node.sideEffects) ? node.sideEffects.filter((sideEffect) => Boolean(sideEffect) && typeof sideEffect === "object").map((sideEffect) => ({ node, sideEffect })) : []
39632
- );
39633
- }
39634
- function readFlowTree(slug) {
39635
- const path42 = join3(flowsDir(), slug, "_data.json");
39636
- if (!existsSync5(path42)) {
39637
- failLocal(`No form "${slug}". Run "baker flows list" to see the forms in this workspace.`);
39638
- }
39639
- try {
39640
- return JSON.parse(readFileSync11(path42, "utf-8"));
39641
- } catch (error) {
39642
- failLocal(`Could not read form "${slug}": ${error instanceof Error ? error.message : String(error)}`);
39643
- }
39644
- }
39645
- function isConfigured(value) {
39646
- return typeof value === "string" && value.length > 0;
39647
- }
39648
- function nodeMeta(record) {
39649
- const nodeId = typeof record.id === "string" ? record.id : "";
39650
- const nodeName = typeof record.name === "string" ? record.name : void 0;
39651
- return { nodeId, ...nodeName ? { nodeName } : {} };
39652
- }
39653
- function widgetNodeStatus(record, meta) {
39654
- const nodeData = record.nodeData;
39655
- const nodeType = nodeData?.type;
39656
- if (typeof nodeType !== "string" || !FLOW_RESOURCE_NODE_TYPES.includes(nodeType)) {
39657
- return null;
39658
- }
39659
- const external = nodeData?.form?.external;
39660
- const selected = external != null && typeof external === "object";
39661
- const name = selected ? external.name : void 0;
39662
- return {
39663
- ...meta,
39664
- target: "node",
39665
- kind: nodeType,
39666
- status: selected ? `${nodeType} \u2014 selected${typeof name === "string" ? ` "${name}"` : ""}` : `${nodeType} \u2014 not selected`,
39667
- needsInput: !selected
39668
- };
39669
- }
39670
- function sideEffectNeedsInput(raw) {
39671
- const effect = raw;
39672
- const type = effect?.type;
39673
- if (typeof type !== "string" || !FLOW_SECRET_SIDE_EFFECT_TYPES.includes(type)) {
39674
- return false;
39675
- }
39676
- const configured = isConfigured(effect?.encryptedConfig);
39677
- if (!isFlowOauthSideEffect(type)) return !configured;
39678
- return !isConfigured(effect?.oauthProviderId) || !configured;
39679
- }
39680
- function sideEffectStatus(raw, meta) {
39681
- const effect = raw;
39682
- const type = effect?.type;
39683
- if (typeof type !== "string" || !FLOW_SECRET_SIDE_EFFECT_TYPES.includes(type)) {
39684
- return null;
39685
- }
39686
- const configured = isConfigured(effect?.encryptedConfig);
39687
- const oauth = isFlowOauthSideEffect(type);
39688
- const connected = isConfigured(effect?.oauthProviderId);
39689
- const needs = FLOW_SECRET_FIELDS[type];
39690
- const status = oauth ? `${type} \u2014 connection ${connected ? "[connected]" : "[needs connection]"}, config ${configured ? "[set]" : "[missing]"}` : configured ? `${type} \u2014 [configured]` : `${type} \u2014 [missing]${needs.length > 0 ? ` (needs: ${needs.join(", ")})` : ""}`;
39691
- return {
39692
- ...meta,
39693
- target: "sideEffect",
39694
- ...typeof effect?.id === "string" ? { sideEffectId: effect.id } : {},
39695
- kind: type,
39696
- status,
39697
- needsInput: sideEffectNeedsInput(effect)
39698
- };
39699
- }
39700
- function requestFlowInputCall(slug, status) {
39701
- const args = status.target === "node" ? `{ target: "node", flowSlug: "${slug}", nodeId: "${status.nodeId}", nodeType: "${status.kind}" }` : `{ target: "sideEffect", flowSlug: "${slug}", nodeId: "${status.nodeId}", sideEffectId: "${status.sideEffectId}", sideEffectType: "${status.kind}" }`;
39702
- return `request_flow_input with ${args}`;
39703
- }
39704
- function collectConfigStatus(node, acc) {
39705
- if (!node || typeof node !== "object") {
39706
- return;
39707
- }
39708
- const record = node;
39709
- const meta = nodeMeta(record);
39710
- const widget = widgetNodeStatus(record, meta);
39711
- if (widget) {
39712
- acc.push(widget);
39713
- }
39714
- if (Array.isArray(record.sideEffects)) {
39715
- for (const raw of record.sideEffects) {
39716
- const status = sideEffectStatus(raw, meta);
39717
- if (status) {
39718
- acc.push(status);
39719
- }
39720
- }
39721
- }
39722
- if (Array.isArray(record.children)) {
39723
- for (const child of record.children) {
39724
- collectConfigStatus(child, acc);
39725
- }
39726
- }
39727
- }
39728
- function redactTree(node) {
39729
- if (Array.isArray(node)) {
39730
- return node.map(redactTree);
39731
- }
39732
- if (!node || typeof node !== "object") {
39733
- return node;
39734
- }
39735
- const out = {};
39736
- for (const [key, value] of Object.entries(node)) {
39737
- if (key === "encryptedConfig") {
39738
- out[key] = isConfigured(value) ? "[configured]" : value;
39739
- } else {
39740
- out[key] = redactTree(value);
39741
- }
39742
- }
39743
- return out;
39744
- }
39745
- function stepNameKey(name) {
39746
- return name.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toUpperCase();
39747
- }
39748
- function isUsableStepNameKey(key) {
39749
- return /^[\p{ID_Start}$_][\p{ID_Continue}$‌‍]*$/u.test(key);
39750
- }
39751
-
39752
- // src/commands/flows/add.ts
39753
40747
  function unknownType(kind, type) {
39754
40748
  writeJson({
39755
40749
  ok: false,
@@ -39840,6 +40834,10 @@ var addNodeCommand = defineCommand120({
39840
40834
  data: { slug, nodeId: node.id, type, parentId: parent.id ?? null, path: flowDataPath(slug) },
39841
40835
  hints: [
39842
40836
  `Now fill in this step's \`ai\` field and its copy by editing ${flowDataPath(slug)}.`,
40837
+ // Said on every step added, because the moment a Form's shape changes
40838
+ // is the moment its outcome can move — and a Form nobody named an
40839
+ // outcome for reports zero conversions rather than an error.
40840
+ `Say what this Form counts before you finish: \`baker analytics conversions --flow ${slug}\` lists every ending already spelled as an event key, including this step's. Nothing in _data.json decides it.`,
39843
40841
  ...entry.owners?.["nodeData.form.external"] === "request_flow_input" ? [
39844
40842
  `A ${type} node's resource is picked by the user: call request_flow_input with { target: "node", flowSlug: "${slug}", nodeId: "${node.id}", nodeType: "${type}" }.`
39845
40843
  ] : [],
@@ -39953,6 +40951,13 @@ var addSideEffectCommand = defineCommand120({
39953
40951
  data: { slug, nodeId: node.id, sideEffectId: id, type, triggerId, path: flowDataPath(slug) },
39954
40952
  hints: [
39955
40953
  `Fires on \`${triggerId}\` \u2014 the moment "${String(node.name ?? node.id)}" raises. A side effect only runs when its triggerId is the one its own step raises, so this is set from the step, never from the side-effect type.`,
40954
+ // A pixel side effect is the thing most often mistaken for "setting the
40955
+ // conversion". It tells the ad platform; it tells Baker nothing, and a
40956
+ // Form can fire every pixel it has while every Baker report says it
40957
+ // converted nobody.
40958
+ ...needsUser.some(([, owner]) => owner === "tags") ? [
40959
+ `This reports the conversion to ${type}. It does NOT make Baker count anything: what Baker counts is a named event, set with \`baker analytics conversions --flow ${slug}\`. Do both \u2014 they are two different systems asked the same question.`
40960
+ ] : [],
39956
40961
  ...needsUser.some(([, owner]) => owner === "request_flow_input") ? [
39957
40962
  `Configure it: request_flow_input with { target: "sideEffect", flowSlug: "${slug}", nodeId: "${node.id}", sideEffectId: "${id}", sideEffectType: "${type}" }. Read the destination straight off that call's result.`
39958
40963
  ] : [],
@@ -39966,11 +40971,11 @@ var addSideEffectCommand = defineCommand120({
39966
40971
  });
39967
40972
 
39968
40973
  // src/commands/flows/map.ts
39969
- import { readFileSync as readFileSync13 } from "fs";
40974
+ import { readFileSync as readFileSync14 } from "fs";
39970
40975
  import { defineCommand as defineCommand121 } from "citty";
39971
40976
 
39972
40977
  // src/commands/flows/value-expression.ts
39973
- import { existsSync as existsSync6, readFileSync as readFileSync12 } from "fs";
40978
+ import { existsSync as existsSync6, readFileSync as readFileSync13 } from "fs";
39974
40979
  import { join as join4 } from "path";
39975
40980
  var PREFIXES = ["field", "tracking", "global", "code"];
39976
40981
  function splitParts(raw) {
@@ -40029,10 +41034,10 @@ function parseValueExpression(raw) {
40029
41034
  return parts.map(parsePart);
40030
41035
  }
40031
41036
  function trackingFieldIds() {
40032
- const path42 = join4(flowsDir(), "..", "tracking.ts");
40033
- if (!existsSync6(path42)) return null;
41037
+ const path44 = join4(flowsDir(), "..", "tracking.ts");
41038
+ if (!existsSync6(path44)) return null;
40034
41039
  try {
40035
- const source = readFileSync12(path42, "utf-8");
41040
+ const source = readFileSync13(path44, "utf-8");
40036
41041
  const block2 = source.match(/TRACKING_FIELD_IDS\s*=\s*\[([\s\S]*?)\]\s*as const/)?.[1];
40037
41042
  if (!block2) return null;
40038
41043
  const ids = [...block2.matchAll(/"(tracking\.[a-z0-9_]+)"/g)].map((match) => match[1]);
@@ -40573,13 +41578,13 @@ function specsFromFile(parsed) {
40573
41578
  return `${destField}${type}=${entry?.value ?? ""}`;
40574
41579
  });
40575
41580
  }
40576
- function readSpecFile(path42) {
41581
+ function readSpecFile(path44) {
40577
41582
  let raw;
40578
41583
  try {
40579
- raw = path42 === "-" ? readFileSync13(0, "utf-8") : readFileSync13(path42, "utf-8");
41584
+ raw = path44 === "-" ? readFileSync14(0, "utf-8") : readFileSync14(path44, "utf-8");
40580
41585
  } catch (error) {
40581
41586
  refuse(
40582
- `Could not read ${path42 === "-" ? "the mapping from stdin" : `"${path42}"`}: ${error instanceof Error ? error.message : String(error)}`
41587
+ `Could not read ${path44 === "-" ? "the mapping from stdin" : `"${path44}"`}: ${error instanceof Error ? error.message : String(error)}`
40583
41588
  );
40584
41589
  }
40585
41590
  let parsed;
@@ -40587,7 +41592,7 @@ function readSpecFile(path42) {
40587
41592
  parsed = JSON.parse(raw);
40588
41593
  } catch (error) {
40589
41594
  refuse(
40590
- `${path42 === "-" ? "stdin" : `"${path42}"`} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
41595
+ `${path44 === "-" ? "stdin" : `"${path44}"`} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
40591
41596
  'Expected { "map": { "<destField>": "<value>", \u2026 } }'
40592
41597
  );
40593
41598
  }
@@ -40857,18 +41862,18 @@ var ARRAY_FIELDS = [
40857
41862
  "tagIds"
40858
41863
  ];
40859
41864
  var ARRAY_OWNERS = ["", "body"];
40860
- function dropUnsetOptionals(sideEffect, path42) {
41865
+ function dropUnsetOptionals(sideEffect, path44) {
40861
41866
  return OPTIONAL_STRINGS.flatMap((key) => {
40862
41867
  if (!(key in sideEffect) || sideEffect[key] !== null && sideEffect[key] !== "") return [];
40863
41868
  delete sideEffect[key];
40864
- return [{ path: path42, change: `dropped \`${key}\` (an optional string is absent, never null)` }];
41869
+ return [{ path: path44, change: `dropped \`${key}\` (an optional string is absent, never null)` }];
40865
41870
  });
40866
41871
  }
40867
- function fillNulledArrays(target, prefix, path42) {
41872
+ function fillNulledArrays(target, prefix, path44) {
40868
41873
  return ARRAY_FIELDS.flatMap((key) => {
40869
41874
  if (!(key in target) || target[key] !== null) return [];
40870
41875
  target[key] = [];
40871
- return [{ path: path42, change: `\`${prefix}${key}: null\` \u2192 \`[]\`` }];
41876
+ return [{ path: path44, change: `\`${prefix}${key}: null\` \u2192 \`[]\`` }];
40872
41877
  });
40873
41878
  }
40874
41879
  function sideEffectsOf(node) {
@@ -40878,13 +41883,13 @@ function sideEffectsOf(node) {
40878
41883
  );
40879
41884
  }
40880
41885
  function normalizeSideEffect(sideEffect, where) {
40881
- const path42 = `${where} \u2192 ${String(sideEffect.id ?? "side effect")}`;
41886
+ const path44 = `${where} \u2192 ${String(sideEffect.id ?? "side effect")}`;
40882
41887
  const arrays = ARRAY_OWNERS.flatMap((owner) => {
40883
41888
  const target = owner ? sideEffect[owner] : sideEffect;
40884
41889
  if (!target || typeof target !== "object") return [];
40885
- return fillNulledArrays(target, owner ? `${owner}.` : "", path42);
41890
+ return fillNulledArrays(target, owner ? `${owner}.` : "", path44);
40886
41891
  });
40887
- return [...dropUnsetOptionals(sideEffect, path42), ...arrays];
41892
+ return [...dropUnsetOptionals(sideEffect, path44), ...arrays];
40888
41893
  }
40889
41894
  function normalizeFlowTree(tree) {
40890
41895
  const changes = [];
@@ -41117,9 +42122,15 @@ var listCommand13 = defineCommand124({
41117
42122
  writeJson({
41118
42123
  ok: true,
41119
42124
  data: { flows },
41120
- hints: pending.length > 0 ? [
41121
- `Still waiting on the user: ${pending.map((flow) => `${flow.slug} (${flow.needsConfig})`).join(", ")}. \`baker flows show <slug>\` returns the request_flow_input call for each.`
41122
- ] : []
42125
+ hints: [
42126
+ ...pending.length > 0 ? [
42127
+ `Still waiting on the user: ${pending.map((flow) => `${flow.slug} (${flow.needsConfig})`).join(", ")}. \`baker flows show <slug>\` returns the request_flow_input call for each.`
42128
+ ] : [],
42129
+ // "Set the conversions on the Forms" lands here first, and nothing in
42130
+ // this family can do it — the conversion side effects it does offer
42131
+ // fire a pixel and are not what any Baker report counts.
42132
+ "Setting what a Form COUNTS is not in this family: a conversion is a named event held in Baker. `baker analytics conversions` lists what this company counts; `--flow <slug>` spells one Form's endings as keys you can count, traffic or no traffic."
42133
+ ]
41123
42134
  });
41124
42135
  }
41125
42136
  });
@@ -41147,7 +42158,15 @@ var showCommand4 = defineCommand124({
41147
42158
  data: response,
41148
42159
  // Inert side effects first: an unconfigured destination is work still to
41149
42160
  // do, but a side effect wired to the wrong moment reads as done.
41150
- hints: [...inertSideEffectHints(tree), ...pendingInputHints(slug, config)]
42161
+ hints: [
42162
+ ...inertSideEffectHints(tree),
42163
+ ...pendingInputHints(slug, config),
42164
+ // The question this command is asked — "is this Form wired up?" — has
42165
+ // an answer that is not in this file at all. What a Form counts is a
42166
+ // named event in Baker, and a Form can be perfectly configured, fire
42167
+ // every pixel it has, and still report converting nobody.
42168
+ `What this Form counts as a conversion is NOT in _data.json and not in any side effect here: it is a named event held in Baker. Read it, and this Form's own endings spelled as event keys, with \`baker analytics conversions --flow ${slug}\`.`
42169
+ ]
41151
42170
  });
41152
42171
  }
41153
42172
  });
@@ -41312,7 +42331,7 @@ Examples:
41312
42331
  import { defineCommand as defineCommand126 } from "citty";
41313
42332
 
41314
42333
  // src/commands/ga4/shared.ts
41315
- import { readFileSync as readFileSync14 } from "fs";
42334
+ import { readFileSync as readFileSync15 } from "fs";
41316
42335
  function failValidation3(message) {
41317
42336
  writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message } });
41318
42337
  process.exit(1);
@@ -41328,7 +42347,7 @@ function requireTarget5(args, entity) {
41328
42347
  function readJsonSource(args, flag) {
41329
42348
  const inline = args[flag];
41330
42349
  const file = args.file;
41331
- const raw = typeof file === "string" && file.length > 0 ? readFileSync14(file, "utf8") : typeof inline === "string" && inline.length > 0 ? inline : void 0;
42350
+ const raw = typeof file === "string" && file.length > 0 ? readFileSync15(file, "utf8") : typeof inline === "string" && inline.length > 0 ? inline : void 0;
41332
42351
  if (raw === void 0) {
41333
42352
  failValidation3(`pass --${flag} with inline JSON or --file with a path to a JSON file`);
41334
42353
  }
@@ -41422,10 +42441,10 @@ async function stageOps(ops) {
41422
42441
  handleError2(err);
41423
42442
  }
41424
42443
  }
41425
- async function draftAction2(path42, body, chat) {
42444
+ async function draftAction2(path44, body, chat) {
41426
42445
  const chatId = resolveChatId(chat);
41427
42446
  try {
41428
- const data = await apiPost(path42, { chatId, ...body });
42447
+ const data = await apiPost(path44, { chatId, ...body });
41429
42448
  writeJsonEnvelope({ ok: true, data });
41430
42449
  return data;
41431
42450
  } catch (err) {
@@ -41679,7 +42698,7 @@ Examples:
41679
42698
  });
41680
42699
 
41681
42700
  // src/commands/ga4/query.ts
41682
- import { appendFileSync as appendFileSync2, existsSync as existsSync7, readFileSync as readFileSync15, writeFileSync as writeFileSync4 } from "fs";
42701
+ import { appendFileSync as appendFileSync2, existsSync as existsSync7, readFileSync as readFileSync16, writeFileSync as writeFileSync4 } from "fs";
41683
42702
  import { resolve as resolve2 } from "path";
41684
42703
  import { defineCommand as defineCommand129 } from "citty";
41685
42704
 
@@ -41770,7 +42789,7 @@ function writeRowsToFile2(filePath, rows, append) {
41770
42789
  writeFileSync4(filePath, content, "utf-8");
41771
42790
  }
41772
42791
  } else if (append && existsSync7(filePath)) {
41773
- const existing = JSON.parse(readFileSync15(filePath, "utf-8"));
42792
+ const existing = JSON.parse(readFileSync16(filePath, "utf-8"));
41774
42793
  writeFileSync4(filePath, JSON.stringify([...existing, ...rows], null, 2), "utf-8");
41775
42794
  } else {
41776
42795
  writeFileSync4(filePath, JSON.stringify(rows, null, 2), "utf-8");
@@ -42622,7 +43641,7 @@ Full guide: __tooling__/docs/tools/baker/ga4.md`
42622
43641
  import { defineCommand as defineCommand135 } from "citty";
42623
43642
 
42624
43643
  // src/commands/gsc/query.ts
42625
- import { appendFileSync as appendFileSync3, existsSync as existsSync8, readFileSync as readFileSync16, writeFileSync as writeFileSync5 } from "fs";
43644
+ import { appendFileSync as appendFileSync3, existsSync as existsSync8, readFileSync as readFileSync17, writeFileSync as writeFileSync5 } from "fs";
42626
43645
  import { resolve as resolve3 } from "path";
42627
43646
  import { defineCommand as defineCommand132 } from "citty";
42628
43647
 
@@ -42770,7 +43789,7 @@ function writeRowsToFile3(filePath, rows, append) {
42770
43789
  writeFileSync5(filePath, content, "utf-8");
42771
43790
  }
42772
43791
  } else if (append && existsSync8(filePath)) {
42773
- const existing = JSON.parse(readFileSync16(filePath, "utf-8"));
43792
+ const existing = JSON.parse(readFileSync17(filePath, "utf-8"));
42774
43793
  writeFileSync5(filePath, JSON.stringify([...existing, ...rows], null, 2), "utf-8");
42775
43794
  } else {
42776
43795
  writeFileSync5(filePath, JSON.stringify(rows, null, 2), "utf-8");
@@ -43147,7 +44166,7 @@ var listCommand14 = defineCommand136({
43147
44166
  }
43148
44167
  }
43149
44168
  });
43150
- var historyCommand = defineCommand136({
44169
+ var historyCommand2 = defineCommand136({
43151
44170
  meta: {
43152
44171
  name: "history",
43153
44172
  description: `Unified account history (audit log): what changed, who did it, and when.
@@ -43839,7 +44858,7 @@ function cropSprite(input, region) {
43839
44858
 
43840
44859
  // src/lib/image/io.ts
43841
44860
  import { randomBytes } from "crypto";
43842
- import { glob as fsGlob, readFile as readFile22, rename, stat as stat6, writeFile as writeFile12 } from "fs/promises";
44861
+ import { glob as fsGlob, readFile as readFile24, rename, stat as stat6, writeFile as writeFile12 } from "fs/promises";
43843
44862
  import { dirname as dirname2, extname as extname2, join as join5, resolve as resolve4 } from "path";
43844
44863
  var REMOTE_RE = /^https?:\/\//i;
43845
44864
  var GLOB_RE = /[*?[\]{}]/;
@@ -43872,11 +44891,11 @@ async function readImageBuffer(pathOrUrl) {
43872
44891
  const { buffer } = await fetchExternalBytes(pathOrUrl, { maxBytes: MAX_REMOTE_IMAGE_BYTES });
43873
44892
  return buffer;
43874
44893
  }
43875
- return readFile22(pathOrUrl);
44894
+ return readFile24(pathOrUrl);
43876
44895
  }
43877
- async function isDirectory(path42) {
44896
+ async function isDirectory(path44) {
43878
44897
  try {
43879
- const s = await stat6(path42);
44898
+ const s = await stat6(path44);
43880
44899
  return s.isDirectory();
43881
44900
  } catch {
43882
44901
  return false;
@@ -44195,13 +45214,13 @@ function resolveDownloadPath({ baseName, extension, out, outIsDirectory: outIsDi
44195
45214
  }
44196
45215
  function disambiguate(paths) {
44197
45216
  const taken = /* @__PURE__ */ new Set();
44198
- return paths.map((path42) => {
44199
- if (!taken.has(path42)) {
44200
- taken.add(path42);
44201
- return path42;
45217
+ return paths.map((path44) => {
45218
+ if (!taken.has(path44)) {
45219
+ taken.add(path44);
45220
+ return path44;
44202
45221
  }
44203
- const ext = extname3(path42);
44204
- const stem = path42.slice(0, path42.length - ext.length);
45222
+ const ext = extname3(path44);
45223
+ const stem = path44.slice(0, path44.length - ext.length);
44205
45224
  let n = 2;
44206
45225
  while (taken.has(`${stem}-${n}${ext}`)) n += 1;
44207
45226
  const unique = `${stem}-${n}${ext}`;
@@ -44327,10 +45346,10 @@ async function runDownloads(plan) {
44327
45346
  const paths = disambiguate(fetched.map((item) => item.path));
44328
45347
  const downloaded = [];
44329
45348
  for (const [index, item] of fetched.entries()) {
44330
- const path42 = paths[index] ?? item.path;
45349
+ const path44 = paths[index] ?? item.path;
44331
45350
  try {
44332
- await atomicWrite(path42, item.buffer);
44333
- downloaded.push({ input: item.input, output: path42, bytes: item.buffer.length, contentType: item.contentType });
45351
+ await atomicWrite(path44, item.buffer);
45352
+ downloaded.push({ input: item.input, output: path44, bytes: item.buffer.length, contentType: item.contentType });
44334
45353
  } catch (err) {
44335
45354
  failed.push({ input: item.input, error: failureMessage(err, "Write failed") });
44336
45355
  }
@@ -47294,8 +48313,8 @@ Full guide: __tooling__/docs/tools/baker/images.md`
47294
48313
  import { defineCommand as defineCommand175 } from "citty";
47295
48314
 
47296
48315
  // src/commands/landing/critique.ts
47297
- import { readdir as readdir10, readFile as readFile24, stat as stat7 } from "fs/promises";
47298
- import path31 from "path";
48316
+ import { readdir as readdir12, readFile as readFile26, stat as stat8 } from "fs/promises";
48317
+ import path33 from "path";
47299
48318
  import { defineCommand as defineCommand164 } from "citty";
47300
48319
 
47301
48320
  // src/engine/landing/lib/constants.ts
@@ -48473,13 +49492,13 @@ function describeCounts(findings) {
48473
49492
 
48474
49493
  // src/commands/landing/snapshot.ts
48475
49494
  import { mkdir as mkdir9, rename as rename2, writeFile as writeFile13 } from "fs/promises";
48476
- import path29 from "path";
49495
+ import path31 from "path";
48477
49496
  var CRITIC_VERSION = "3";
48478
49497
  function critiqueCacheDir(projectRoot) {
48479
- return path29.join(projectRoot, ".cache", "landing-critique");
49498
+ return path31.join(projectRoot, ".cache", "landing-critique");
48480
49499
  }
48481
49500
  function snapshotPath(projectRoot, slug) {
48482
- return path29.join(critiqueCacheDir(projectRoot), `${slug}.json`);
49501
+ return path31.join(critiqueCacheDir(projectRoot), `${slug}.json`);
48483
49502
  }
48484
49503
  async function writeCritiqueSnapshot(projectRoot, snapshot) {
48485
49504
  await mkdir9(critiqueCacheDir(projectRoot), { recursive: true });
@@ -48491,8 +49510,8 @@ async function writeCritiqueSnapshot(projectRoot, snapshot) {
48491
49510
  }
48492
49511
 
48493
49512
  // src/commands/landing/source-version.ts
48494
- import { readFile as readFile23 } from "fs/promises";
48495
- import path30 from "path";
49513
+ import { readdir as readdir11, readFile as readFile25, stat as stat7 } from "fs/promises";
49514
+ import path32 from "path";
48496
49515
  var CRITIQUED_ROOTS = ["src/pages/", "src/components/"];
48497
49516
  async function landingSourceRelPaths(root, slug) {
48498
49517
  const files = await landingGraphFiles(root, slug);
@@ -48501,7 +49520,7 @@ async function landingSourceRelPaths(root, slug) {
48501
49520
  async function readLandingSources(root, slug) {
48502
49521
  const rel = await landingSourceRelPaths(root, slug);
48503
49522
  const out = [];
48504
- for (const r of rel) out.push({ path: r, text: await readFile23(path30.join(root, r), "utf8") });
49523
+ for (const r of rel) out.push({ path: r, text: await readFile25(path32.join(root, r), "utf8") });
48505
49524
  return out;
48506
49525
  }
48507
49526
  async function computeLandingSourceSha(root, slug) {
@@ -48510,7 +49529,7 @@ async function computeLandingSourceSha(root, slug) {
48510
49529
  for (const r of rel) {
48511
49530
  let bytes;
48512
49531
  try {
48513
- bytes = await readFile23(path30.join(root, r));
49532
+ bytes = await readFile25(path32.join(root, r));
48514
49533
  } catch {
48515
49534
  bytes = Buffer.alloc(0);
48516
49535
  }
@@ -48518,6 +49537,47 @@ async function computeLandingSourceSha(root, slug) {
48518
49537
  }
48519
49538
  return sha256Hex(Buffer.concat(parts));
48520
49539
  }
49540
+ async function computeLegacyLandingSourceSha(landingDir) {
49541
+ const rel = [];
49542
+ if (await isFile2(path32.join(landingDir, "index.astro"))) rel.push("index.astro");
49543
+ for (const abs of await walkAstro(path32.join(landingDir, "_components"))) {
49544
+ rel.push(path32.relative(landingDir, abs).split(path32.sep).join("/"));
49545
+ }
49546
+ rel.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
49547
+ const parts = [];
49548
+ for (const r of rel) {
49549
+ let bytes;
49550
+ try {
49551
+ bytes = await readFile25(path32.join(landingDir, r));
49552
+ } catch {
49553
+ bytes = Buffer.alloc(0);
49554
+ }
49555
+ parts.push(Buffer.from(`${r}\0${bytes.length}\0`), bytes);
49556
+ }
49557
+ return sha256Hex(Buffer.concat(parts));
49558
+ }
49559
+ async function isFile2(p) {
49560
+ try {
49561
+ return (await stat7(p)).isFile();
49562
+ } catch {
49563
+ return false;
49564
+ }
49565
+ }
49566
+ async function walkAstro(dir) {
49567
+ let entries;
49568
+ try {
49569
+ entries = await readdir11(dir, { withFileTypes: true });
49570
+ } catch {
49571
+ return [];
49572
+ }
49573
+ const out = [];
49574
+ for (const entry of entries) {
49575
+ const abs = path32.join(dir, entry.name);
49576
+ if (entry.isDirectory()) out.push(...await walkAstro(abs));
49577
+ else if (entry.isFile() && entry.name.endsWith(".astro")) out.push(abs);
49578
+ }
49579
+ return out;
49580
+ }
48521
49581
 
48522
49582
  // src/commands/landing/critique.ts
48523
49583
  registerSchema({
@@ -48573,7 +49633,7 @@ var critiqueCommand2 = defineCommand164({
48573
49633
  { availableSlugs: await listLandingSlugs(projectRoot) }
48574
49634
  );
48575
49635
  }
48576
- if (!await isDir2(path31.resolve(projectRoot, "src", "pages", slug))) {
49636
+ if (!await isDir2(path33.resolve(projectRoot, "src", "pages", slug))) {
48577
49637
  fail6("NOT_FOUND", `No landing at src/pages/${slug}/`, {
48578
49638
  availableSlugs: await listLandingSlugs(projectRoot)
48579
49639
  });
@@ -48613,15 +49673,17 @@ var critiqueCommand2 = defineCommand164({
48613
49673
  }
48614
49674
  });
48615
49675
  async function critiqueOne(projectRoot, slug, brand, competitors) {
48616
- const [sources, sourceSha] = await Promise.all([
49676
+ const [sources, compositionSha, sourceSha] = await Promise.all([
48617
49677
  readLandingSources(projectRoot, slug),
48618
- computeLandingSourceSha(projectRoot, slug)
49678
+ computeLandingSourceSha(projectRoot, slug),
49679
+ computeLegacyLandingSourceSha(path33.resolve(projectRoot, "src", "pages", slug))
48619
49680
  ]);
48620
49681
  const report = critiqueLanding({ slug, sources, brand, competitors });
48621
49682
  let snapshotFailed = false;
48622
49683
  try {
48623
49684
  await writeCritiqueSnapshot(projectRoot, {
48624
49685
  slug,
49686
+ compositionSha,
48625
49687
  sourceSha,
48626
49688
  criticVersion: CRITIC_VERSION,
48627
49689
  at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -48634,18 +49696,18 @@ async function critiqueOne(projectRoot, slug, brand, competitors) {
48634
49696
  return { slug, report, snapshotFailed };
48635
49697
  }
48636
49698
  async function readCompetitorNames(projectRoot) {
48637
- const dir = path31.join(projectRoot, "src", "content", "competitors");
49699
+ const dir = path33.join(projectRoot, "src", "content", "competitors");
48638
49700
  let entries;
48639
49701
  try {
48640
- entries = (await readdir10(dir)).filter((f) => f.endsWith(".md"));
49702
+ entries = (await readdir12(dir)).filter((f) => f.endsWith(".md"));
48641
49703
  } catch {
48642
49704
  return [];
48643
49705
  }
48644
49706
  const names = [];
48645
49707
  for (const entry of entries) {
48646
- names.push(path31.basename(entry, ".md").replace(/[-_]+/g, " "));
49708
+ names.push(path33.basename(entry, ".md").replace(/[-_]+/g, " "));
48647
49709
  try {
48648
- const head = (await readFile24(path31.join(dir, entry), "utf8")).slice(0, 2e3);
49710
+ const head = (await readFile26(path33.join(dir, entry), "utf8")).slice(0, 2e3);
48649
49711
  const titled = /^\s*(?:title|name)\s*:\s*["']?([^"'\n]+)["']?\s*$/im.exec(head);
48650
49712
  if (titled?.[1]) names.push(titled[1].trim());
48651
49713
  } catch {
@@ -48655,7 +49717,7 @@ async function readCompetitorNames(projectRoot) {
48655
49717
  }
48656
49718
  async function listLandingSlugs(projectRoot) {
48657
49719
  try {
48658
- const entries = await readdir10(path31.join(projectRoot, "src", "pages"), { withFileTypes: true });
49720
+ const entries = await readdir12(path33.join(projectRoot, "src", "pages"), { withFileTypes: true });
48659
49721
  return entries.filter((e) => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith(".")).map((e) => e.name).sort();
48660
49722
  } catch {
48661
49723
  return [];
@@ -48674,7 +49736,7 @@ function present(f) {
48674
49736
  }
48675
49737
  async function isDir2(p) {
48676
49738
  try {
48677
- return (await stat7(p)).isDirectory();
49739
+ return (await stat8(p)).isDirectory();
48678
49740
  } catch {
48679
49741
  return false;
48680
49742
  }
@@ -48807,7 +49869,7 @@ var addCommand = defineCommand165({
48807
49869
 
48808
49870
  // src/commands/landing/inspiration/code.ts
48809
49871
  import { mkdir as mkdir10, writeFile as writeFile14 } from "fs/promises";
48810
- import path32 from "path";
49872
+ import path34 from "path";
48811
49873
  import { defineCommand as defineCommand166 } from "citty";
48812
49874
  registerSchema({
48813
49875
  command: "landing.inspiration.code",
@@ -48830,9 +49892,9 @@ var codeCommand = defineCommand166({
48830
49892
  try {
48831
49893
  const id = args.id;
48832
49894
  const data = await apiGet("/api/landing-inspiration/section-code", { id });
48833
- const dir = path32.join(process.cwd(), ".baker", "inspiration", id);
49895
+ const dir = path34.join(process.cwd(), ".baker", "inspiration", id);
48834
49896
  await mkdir10(dir, { recursive: true });
48835
- const file = path32.join(dir, "section.html");
49897
+ const file = path34.join(dir, "section.html");
48836
49898
  await writeFile14(file, data.html);
48837
49899
  const hints2 = [INSPIRATION_HINTS.structureNotCopy, INSPIRATION_HINTS.adapt];
48838
49900
  const fidelity = fidelityHint(data.fidelity);
@@ -48842,7 +49904,7 @@ var codeCommand = defineCommand166({
48842
49904
  ok: true,
48843
49905
  data: {
48844
49906
  id,
48845
- file: path32.relative(process.cwd(), file),
49907
+ file: path34.relative(process.cwd(), file),
48846
49908
  bytes: data.html.length,
48847
49909
  fidelity: data.fidelity,
48848
49910
  reproduction_notes: data.reproductionNotes,
@@ -49274,7 +50336,7 @@ function classifyCaptureFailure(error) {
49274
50336
 
49275
50337
  // src/engine/landing-library/run.ts
49276
50338
  import { mkdir as mkdir11, writeFile as writeFile16 } from "fs/promises";
49277
- import path34 from "path";
50339
+ import path36 from "path";
49278
50340
 
49279
50341
  // ../proxy/src/preflight.ts
49280
50342
  import http from "http";
@@ -49413,7 +50475,7 @@ var inPageCollectUsedCss = (selector) => {
49413
50475
  }
49414
50476
  return false;
49415
50477
  };
49416
- const walk = (rules, sink) => {
50478
+ const walk2 = (rules, sink) => {
49417
50479
  for (const rule of Array.from(rules)) {
49418
50480
  totalRules++;
49419
50481
  if (rule instanceof CSSStyleRule) {
@@ -49434,7 +50496,7 @@ var inPageCollectUsedCss = (selector) => {
49434
50496
  const grouping = rule instanceof CSSMediaRule || rule instanceof CSSSupportsRule || typeof CSSLayerBlockRule !== "undefined" && rule instanceof CSSLayerBlockRule || typeof CSSContainerRule !== "undefined" && rule instanceof CSSContainerRule;
49435
50497
  if (grouping) {
49436
50498
  const inner = [];
49437
- walk(rule.cssRules, inner);
50499
+ walk2(rule.cssRules, inner);
49438
50500
  if (inner.length === 0) continue;
49439
50501
  const condition = rule.conditionText ?? "";
49440
50502
  const prelude = rule instanceof CSSMediaRule ? `@media ${condition}` : rule.cssText.split("{")[0]?.trim();
@@ -49448,7 +50510,7 @@ ${inner.join("\n")}
49448
50510
  const owner = sheet.ownerNode;
49449
50511
  if (owner?.hasAttribute?.("data-baker-freeze")) continue;
49450
50512
  try {
49451
- walk(sheet.cssRules, kept);
50513
+ walk2(sheet.cssRules, kept);
49452
50514
  } catch {
49453
50515
  if (sheet.href) unreadableHrefs.push(sheet.href);
49454
50516
  }
@@ -49899,7 +50961,7 @@ var inPageCollectMotion = (selector) => {
49899
50961
  else if (infinite) loop.push(effect);
49900
50962
  else entrance.push(effect);
49901
50963
  };
49902
- const walk = (rules, insideScrollTimeline) => {
50964
+ const walk2 = (rules, insideScrollTimeline) => {
49903
50965
  for (const rule of Array.from(rules)) {
49904
50966
  if (rule instanceof CSSKeyframesRule) {
49905
50967
  keyframesByName.set(rule.name, rule.cssText);
@@ -49917,17 +50979,17 @@ var inPageCollectMotion = (selector) => {
49917
50979
  respectsReducedMotion = true;
49918
50980
  continue;
49919
50981
  }
49920
- walk(rule.cssRules, insideScrollTimeline);
50982
+ walk2(rule.cssRules, insideScrollTimeline);
49921
50983
  continue;
49922
50984
  }
49923
50985
  const grouping = rule;
49924
- if (grouping.cssRules) walk(grouping.cssRules, insideScrollTimeline);
50986
+ if (grouping.cssRules) walk2(grouping.cssRules, insideScrollTimeline);
49925
50987
  }
49926
50988
  };
49927
50989
  for (const sheet of Array.from(document.styleSheets)) {
49928
50990
  if (sheet.ownerNode?.hasAttribute?.("data-baker-freeze")) continue;
49929
50991
  try {
49930
- walk(sheet.cssRules, false);
50992
+ walk2(sheet.cssRules, false);
49931
50993
  } catch {
49932
50994
  }
49933
50995
  }
@@ -50690,9 +51752,9 @@ async function renderBundleToPng(browser, html, viewportWidth, options = {}) {
50690
51752
 
50691
51753
  // src/engine/landing-library/report.ts
50692
51754
  import { writeFile as writeFile15 } from "fs/promises";
50693
- import path33 from "path";
51755
+ import path35 from "path";
50694
51756
  async function writeCaptureReport(manifest, outDir) {
50695
- const file = path33.join(outDir, "report.html");
51757
+ const file = path35.join(outDir, "report.html");
50696
51758
  await writeFile15(file, renderReport(manifest));
50697
51759
  return file;
50698
51760
  }
@@ -50871,32 +51933,32 @@ async function reproducePage(args) {
50871
51933
  const { browser, page, outDir, pageUrl, livePageShot } = args;
50872
51934
  const built = await buildSectionBundle(page, "body", pageUrl).catch(() => null);
50873
51935
  if (!built) return { bundle: null, fidelity: null };
50874
- await writeFile16(path34.join(outDir, "page.html"), built.html);
51936
+ await writeFile16(path36.join(outDir, "page.html"), built.html);
50875
51937
  const rendered = await renderBundleToPng(browser, built.html, DESKTOP_VIEWPORT.width, {
50876
51938
  wholePage: true,
50877
51939
  timeoutMs: 6e4
50878
51940
  });
50879
51941
  if (!rendered || !livePageShot) return { bundle: "page.html", fidelity: null };
50880
- await writeFile16(path34.join(outDir, "page-rendered.png"), rendered);
51942
+ await writeFile16(path36.join(outDir, "page-rendered.png"), rendered);
50881
51943
  const { score, note } = await scoreFidelity(livePageShot, rendered);
50882
51944
  return { bundle: "page.html", fidelity: score, ...note ? { fidelityNote: note } : {} };
50883
51945
  }
50884
51946
  async function captureOneSection(args) {
50885
51947
  const { browser, page, candidate, sectionsDir, outDir, pageUrl, withCode } = args;
50886
- const dir = path34.join(sectionsDir, String(candidate.index).padStart(2, "0"));
51948
+ const dir = path36.join(sectionsDir, String(candidate.index).padStart(2, "0"));
50887
51949
  await mkdir11(dir, { recursive: true });
50888
51950
  const desktop = await captureSection(page, candidate);
50889
- if (desktop) await writeFile16(path34.join(dir, "desktop.png"), desktop);
51951
+ if (desktop) await writeFile16(path36.join(dir, "desktop.png"), desktop);
50890
51952
  const visualHash = desktop ? await perceptualHash(desktop) : null;
50891
51953
  const motion = await collectMotion(page, candidate.selector);
50892
51954
  const built = withCode ? await buildSectionBundle(page, candidate.selector, pageUrl) : null;
50893
51955
  let fidelity = null;
50894
51956
  let fidelityNote;
50895
51957
  if (built) {
50896
- await writeFile16(path34.join(dir, "section.html"), built.html);
51958
+ await writeFile16(path36.join(dir, "section.html"), built.html);
50897
51959
  const rendered = await renderBundleToPng(browser, built.html, DESKTOP_VIEWPORT.width);
50898
51960
  if (rendered && desktop) {
50899
- await writeFile16(path34.join(dir, "section-rendered.png"), rendered);
51961
+ await writeFile16(path36.join(dir, "section-rendered.png"), rendered);
50900
51962
  const result = await scoreFidelity(desktop, rendered);
50901
51963
  fidelity = result.score;
50902
51964
  fidelityNote = result.note;
@@ -50904,9 +51966,9 @@ async function captureOneSection(args) {
50904
51966
  }
50905
51967
  return {
50906
51968
  ...candidate,
50907
- desktopShot: desktop ? path34.relative(outDir, path34.join(dir, "desktop.png")) : null,
51969
+ desktopShot: desktop ? path36.relative(outDir, path36.join(dir, "desktop.png")) : null,
50908
51970
  mobileShot: null,
50909
- bundle: built ? path34.relative(outDir, path34.join(dir, "section.html")) : null,
51971
+ bundle: built ? path36.relative(outDir, path36.join(dir, "section.html")) : null,
50910
51972
  fidelity,
50911
51973
  ...fidelityNote ? { fidelityNote } : {},
50912
51974
  ...built ? { cssStats: built.stats } : {},
@@ -50925,9 +51987,9 @@ async function captureMobileShots(args) {
50925
51987
  for (const section of sections) {
50926
51988
  const shot = await captureSectionOnMobile(mobile.page, section);
50927
51989
  if (!shot) continue;
50928
- const file = path34.join(sectionsDir, String(section.index).padStart(2, "0"), "mobile.png");
51990
+ const file = path36.join(sectionsDir, String(section.index).padStart(2, "0"), "mobile.png");
50929
51991
  await writeFile16(file, shot);
50930
- section.mobileShot = path34.relative(outDir, file);
51992
+ section.mobileShot = path36.relative(outDir, file);
50931
51993
  }
50932
51994
  } finally {
50933
51995
  await mobile.context.close();
@@ -50942,10 +52004,10 @@ async function captureMotionTakes(args) {
50942
52004
  const filmOne = async (section) => {
50943
52005
  const take = await captureMotionTake(browser, pageUrl, section.selector).catch(() => null);
50944
52006
  if (!take) return;
50945
- const dir = path34.join(sectionsDir, String(section.index).padStart(2, "0"));
50946
- const file = path34.join(dir, "motion-filmstrip.png");
52007
+ const dir = path36.join(sectionsDir, String(section.index).padStart(2, "0"));
52008
+ const file = path36.join(dir, "motion-filmstrip.png");
50947
52009
  await writeFile16(file, take.filmstrip);
50948
- section.motionFilmstrip = path34.relative(outDir, file);
52010
+ section.motionFilmstrip = path36.relative(outDir, file);
50949
52011
  log(` [${section.index}] ${section.motion.summary}`);
50950
52012
  };
50951
52013
  const queue = [...moving];
@@ -51002,7 +52064,7 @@ async function captureAlternateViews(args) {
51002
52064
  async function reproduceWholePage(args) {
51003
52065
  const { browser, page, outDir, pageUrl, withCode, log } = args;
51004
52066
  const fullPage = await page.screenshot({ type: "png", fullPage: true }).catch(() => null);
51005
- if (fullPage) await writeFile16(path34.join(outDir, "full-page.png"), fullPage);
52067
+ if (fullPage) await writeFile16(path36.join(outDir, "full-page.png"), fullPage);
51006
52068
  if (!withCode) return { bundle: null, fidelity: null };
51007
52069
  const reproduction = await reproducePage({ browser, page, outDir, pageUrl, livePageShot: fullPage });
51008
52070
  log(`page reproduction: ${reproduction.fidelity === null ? "unavailable" : reproduction.fidelity.toFixed(2)}`);
@@ -51073,7 +52135,7 @@ async function openViaLadder(args) {
51073
52135
  async function scrapeLanding(options) {
51074
52136
  const timeoutMs = options.timeoutMs ?? 45e3;
51075
52137
  const log = options.onProgress ?? (() => void 0);
51076
- const sectionsDir = path34.join(options.outDir, "sections");
52138
+ const sectionsDir = path36.join(options.outDir, "sections");
51077
52139
  const nonPublic = refuseNonPublicUrl(options.url);
51078
52140
  if (nonPublic) {
51079
52141
  throw new BlockedPageError({
@@ -51135,7 +52197,7 @@ async function scrapeLanding(options) {
51135
52197
  security: prepared.security,
51136
52198
  captureTier: tier
51137
52199
  };
51138
- await writeFile16(path34.join(options.outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
52200
+ await writeFile16(path36.join(options.outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
51139
52201
  `);
51140
52202
  if (options.report !== false) {
51141
52203
  const reportPath = await writeCaptureReport(manifest, options.outDir);
@@ -51150,28 +52212,28 @@ async function scrapeLanding(options) {
51150
52212
 
51151
52213
  // src/commands/landing/inspiration/captureOut.ts
51152
52214
  import { existsSync as existsSync9 } from "fs";
51153
- import path35 from "path";
52215
+ import path37 from "path";
51154
52216
  var SCRATCH_DIR = ".baker";
51155
52217
  function isWithin(parent, target) {
51156
- const relative = path35.relative(parent, target);
51157
- return relative === "" || !relative.startsWith("..") && !path35.isAbsolute(relative);
52218
+ const relative = path37.relative(parent, target);
52219
+ return relative === "" || !relative.startsWith("..") && !path37.isAbsolute(relative);
51158
52220
  }
51159
52221
  function findRepoRoot(from) {
51160
- let dir = path35.resolve(from);
52222
+ let dir = path37.resolve(from);
51161
52223
  for (; ; ) {
51162
- if (existsSync9(path35.join(dir, ".git"))) return dir;
51163
- const parent = path35.dirname(dir);
52224
+ if (existsSync9(path37.join(dir, ".git"))) return dir;
52225
+ const parent = path37.dirname(dir);
51164
52226
  if (parent === dir) return null;
51165
52227
  dir = parent;
51166
52228
  }
51167
52229
  }
51168
52230
  function checkCaptureOut(out, options) {
51169
52231
  const { cwd, repoRoot } = options;
51170
- const resolved = path35.resolve(cwd, out);
52232
+ const resolved = path37.resolve(cwd, out);
51171
52233
  if (repoRoot === null || !isWithin(repoRoot, resolved)) return { ok: true };
51172
- const scratch = path35.join(repoRoot, SCRATCH_DIR);
52234
+ const scratch = path37.join(repoRoot, SCRATCH_DIR);
51173
52235
  if (isWithin(scratch, resolved)) return { ok: true };
51174
- const suggestion = path35.posix.join(SCRATCH_DIR, "teardowns", path35.basename(resolved) || "capture");
52236
+ const suggestion = path37.posix.join(SCRATCH_DIR, "teardowns", path37.basename(resolved) || "capture");
51175
52237
  return {
51176
52238
  ok: false,
51177
52239
  error: {
@@ -51345,12 +52407,12 @@ var scrapeCommand = defineCommand169({
51345
52407
  });
51346
52408
 
51347
52409
  // src/commands/landing/inspiration/search.ts
51348
- import path37 from "path";
52410
+ import path39 from "path";
51349
52411
  import { defineCommand as defineCommand170 } from "citty";
51350
52412
 
51351
52413
  // src/commands/landing/inspiration/shot.ts
51352
52414
  import { mkdir as mkdir12, writeFile as writeFile17 } from "fs/promises";
51353
- import path36 from "path";
52415
+ import path38 from "path";
51354
52416
  import sharp6 from "sharp";
51355
52417
  var READABLE_SHOT = {
51356
52418
  maxWidth: 1440,
@@ -51376,9 +52438,9 @@ async function downloadReadableShot(url, file) {
51376
52438
  const response = await fetch(url);
51377
52439
  if (!response.ok) return null;
51378
52440
  const shot = await toReadableShot(Buffer.from(await response.arrayBuffer()));
51379
- await mkdir12(path36.dirname(file), { recursive: true });
52441
+ await mkdir12(path38.dirname(file), { recursive: true });
51380
52442
  await writeFile17(file, shot);
51381
- return path36.relative(process.cwd(), file);
52443
+ return path38.relative(process.cwd(), file);
51382
52444
  } catch {
51383
52445
  return null;
51384
52446
  }
@@ -51470,13 +52532,13 @@ function buildSearchBody(args) {
51470
52532
  return body;
51471
52533
  }
51472
52534
  async function downloadShots(results) {
51473
- const dir = path37.join(process.cwd(), ".baker", "inspiration");
52535
+ const dir = path39.join(process.cwd(), ".baker", "inspiration");
51474
52536
  const saved = /* @__PURE__ */ new Map();
51475
52537
  await Promise.all(
51476
52538
  results.map(async (result) => {
51477
52539
  const file = await downloadReadableShot(
51478
52540
  result.desktopShotUrl,
51479
- path37.join(dir, `${result.id}.${READABLE_SHOT.extension}`)
52541
+ path39.join(dir, `${result.id}.${READABLE_SHOT.extension}`)
51480
52542
  );
51481
52543
  if (file) saved.set(result.id, file);
51482
52544
  })
@@ -51704,7 +52766,7 @@ var sequencesCommand = defineCommand171({
51704
52766
  });
51705
52767
 
51706
52768
  // src/commands/landing/inspiration/view.ts
51707
- import path38 from "path";
52769
+ import path40 from "path";
51708
52770
  import { defineCommand as defineCommand172 } from "citty";
51709
52771
  registerSchema({
51710
52772
  command: "landing.inspiration.view",
@@ -51737,12 +52799,12 @@ var viewCommand2 = defineCommand172({
51737
52799
  const id = args.id;
51738
52800
  const data = await apiGet("/api/landing-inspiration/section", { id });
51739
52801
  const section = data.section;
51740
- const dir = path38.join(process.cwd(), ".baker", "inspiration", id);
52802
+ const dir = path40.join(process.cwd(), ".baker", "inspiration", id);
51741
52803
  const ext = READABLE_SHOT.extension;
51742
52804
  const [desktop, mobile, filmstrip] = await Promise.all([
51743
- downloadReadableShot(section.desktopShotUrl, path38.join(dir, `desktop.${ext}`)),
51744
- downloadReadableShot(section.mobileShotUrl, path38.join(dir, `mobile.${ext}`)),
51745
- downloadReadableShot(section.motionFilmstripUrl, path38.join(dir, `motion-filmstrip.${ext}`))
52805
+ downloadReadableShot(section.desktopShotUrl, path40.join(dir, `desktop.${ext}`)),
52806
+ downloadReadableShot(section.mobileShotUrl, path40.join(dir, `mobile.${ext}`)),
52807
+ downloadReadableShot(section.motionFilmstripUrl, path40.join(dir, `motion-filmstrip.${ext}`))
51746
52808
  ]);
51747
52809
  const full = args.full;
51748
52810
  const hints2 = [INSPIRATION_HINTS.structureNotCopy, INSPIRATION_HINTS.adapt];
@@ -51848,8 +52910,8 @@ Full guide: __tooling__/docs/tools/baker/landing.md`
51848
52910
 
51849
52911
  // src/commands/landing/variant.ts
51850
52912
  import { randomUUID as randomUUID2 } from "crypto";
51851
- import { mkdir as mkdir13, readdir as readdir11, readFile as readFile25, stat as stat8, writeFile as writeFile18 } from "fs/promises";
51852
- import path39 from "path";
52913
+ import { mkdir as mkdir13, readdir as readdir13, readFile as readFile27, stat as stat9, writeFile as writeFile18 } from "fs/promises";
52914
+ import path41 from "path";
51853
52915
  import { defineCommand as defineCommand174 } from "citty";
51854
52916
  registerSchema({
51855
52917
  command: "landing.variant",
@@ -51876,7 +52938,7 @@ registerSchema({
51876
52938
  var SLUG_RE2 = /^[a-z0-9][a-z0-9-]*$/;
51877
52939
  async function readInternalId(definitionPath) {
51878
52940
  try {
51879
- const text2 = await readFile25(definitionPath, "utf8");
52941
+ const text2 = await readFile27(definitionPath, "utf8");
51880
52942
  return /^internalId:\s*"?([^"\s]+)"?\s*$/m.exec(text2)?.[1] ?? null;
51881
52943
  } catch {
51882
52944
  return null;
@@ -51885,7 +52947,7 @@ async function readInternalId(definitionPath) {
51885
52947
  async function archivedVariantNumbers(root, internalId) {
51886
52948
  if (!internalId) return [];
51887
52949
  try {
51888
- const entries = await readdir11(path39.resolve(root, "src/_variants", internalId), { withFileTypes: true });
52950
+ const entries = await readdir13(path41.resolve(root, "src/_variants", internalId), { withFileTypes: true });
51889
52951
  return entries.filter((entry) => entry.isDirectory()).map((entry) => Number(entry.name)).filter((n) => Number.isInteger(n) && n > 0);
51890
52952
  } catch {
51891
52953
  return [];
@@ -51900,14 +52962,14 @@ function fail7(code, message, fix) {
51900
52962
  }
51901
52963
  async function isDir3(p) {
51902
52964
  try {
51903
- return (await stat8(p)).isDirectory();
52965
+ return (await stat9(p)).isDirectory();
51904
52966
  } catch {
51905
52967
  return false;
51906
52968
  }
51907
52969
  }
51908
52970
  async function exists(p) {
51909
52971
  try {
51910
- await stat8(p);
52972
+ await stat9(p);
51911
52973
  return true;
51912
52974
  } catch {
51913
52975
  return false;
@@ -51915,7 +52977,7 @@ async function exists(p) {
51915
52977
  }
51916
52978
  async function listLandingSlugs2(root) {
51917
52979
  try {
51918
- const entries = await readdir11(path39.resolve(root, "src", "pages"), { withFileTypes: true });
52980
+ const entries = await readdir13(path41.resolve(root, "src", "pages"), { withFileTypes: true });
51919
52981
  return entries.filter((e) => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith(".")).map((e) => e.name).sort();
51920
52982
  } catch {
51921
52983
  return [];
@@ -51924,14 +52986,14 @@ async function listLandingSlugs2(root) {
51924
52986
  async function listComponents(componentsDir, prefix = "") {
51925
52987
  let entries;
51926
52988
  try {
51927
- entries = await readdir11(componentsDir, { withFileTypes: true });
52989
+ entries = await readdir13(componentsDir, { withFileTypes: true });
51928
52990
  } catch {
51929
52991
  return [];
51930
52992
  }
51931
52993
  const out = [];
51932
52994
  for (const entry of entries) {
51933
52995
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
51934
- if (entry.isDirectory()) out.push(...await listComponents(path39.join(componentsDir, entry.name), rel));
52996
+ if (entry.isDirectory()) out.push(...await listComponents(path41.join(componentsDir, entry.name), rel));
51935
52997
  else if (entry.name.endsWith(".astro")) out.push(rel);
51936
52998
  }
51937
52999
  return out.sort();
@@ -51945,27 +53007,39 @@ function resolveForkName(requested, available) {
51945
53007
  const matches = available.filter((a) => basename4(a) === cleaned || basename4(a) === `${cleaned}.astro`);
51946
53008
  return matches.length === 1 ? matches[0] ?? null : null;
51947
53009
  }
51948
- function parseForks(fork) {
51949
- const list = Array.isArray(fork) ? fork : fork === void 0 || fork === null ? [] : [fork];
51950
- const names = list.filter((s) => typeof s === "string").flatMap((s) => s.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
53010
+ function parseForks(fork, rawArgs) {
53011
+ const names = repeatedValues(rawArgs, "fork", fork).flatMap((s) => s.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
51951
53012
  return [...new Set(names)];
51952
53013
  }
53014
+ function pageInTest(status, page) {
53015
+ for (const test of status.experiments) {
53016
+ if (test.landingSlug !== page) continue;
53017
+ if (test.status !== "finished") {
53018
+ return `${page} is already in a test (${test.experimentId}, against ${test.variantSlug}, ${test.status}). One page has one test at a time \u2014 read it with \`baker experiment status --landing ${page}\`, and end it with \`baker experiment finish\` before building the next version.`;
53019
+ }
53020
+ if (test.fold === "owed" || test.fold === "landing") {
53021
+ return `The last test on ${page} was won by ${test.variantSlug}, and the page is still being updated to match \u2014 run \`baker experiment fold\` and publish before building the next version.`;
53022
+ }
53023
+ }
53024
+ return null;
53025
+ }
51953
53026
  function buildVariantDefinition(controlDefinition, opts) {
51954
- let out = controlDefinition;
53027
+ const frontmatter = controlDefinition.match(/^---\n[\s\S]*?\n---/)?.[0] ?? "---\n---";
53028
+ let out = frontmatter;
51955
53029
  out = out.replace(/^internalId:.*$/m, `internalId: "${opts.internalId}"`);
51956
53030
  out = out.replace(/^internalTitle:\s*(.*)$/m, (_m, title) => `internalTitle: ${title.trim()} (variant)`);
51957
53031
  const shared = opts.forks.length > 0 ? opts.forks.join(", ") : "nothing yet";
51958
53032
  const note = [
51959
53033
  "",
51960
- `## A/B test \u2014 alternative version of \`${opts.controlSlug}\``,
53034
+ `# Version ${variantNumberOf(opts.variantSlug) ?? "?"} of \`${opts.controlSlug}\` \u2014 for an A/B test`,
51961
53035
  "",
51962
- `This page is the alternative arm of a test against \`${opts.controlSlug}\`. Visitors are split at the edge and the address bar keeps saying \`/${opts.controlSlug}/\`, so both arms are the same page seen by different people.`,
53036
+ `This is not a page. It is one version of \`${opts.controlSlug}\`, shown to half of that page's visitors while a test runs; the address bar keeps saying \`/${opts.controlSlug}/\` and this folder has no address of its own. Everything about the page \u2014 the campaign, the audience, the offer, the proof \u2014 is in \`src/pages/${opts.controlSlug}/_definition.md\`, and that is where it is edited. This file only says what is different here.`,
51963
53037
  "",
51964
- `**Only this differs:** ${shared}. Every other section is imported from \`${opts.controlSlug}\`, on purpose \u2014 an edit to the control reaches both arms, so the test keeps measuring the one difference rather than a drift between two copies.`,
53038
+ `**Only this differs:** ${shared}. Every other section is imported from \`${opts.controlSlug}\`, on purpose \u2014 an edit to the page reaches both versions, so the test keeps measuring the one difference rather than a drift between two copies. Edit only the files under \`src/pages/${opts.variantSlug}/_components/\`.`,
51965
53039
  ...opts.because ? ["", `**Because:** ${opts.because}`] : [],
51966
53040
  ...opts.change ? ["", `**We changed:** ${opts.change}`] : [],
51967
53041
  "",
51968
- `Run \`baker experiment start --landing ${opts.controlSlug} --variant ${opts.variantSlug} --because "\u2026" --change "\u2026"\` to begin, and read the result with \`baker experiment status\`.`,
53042
+ `Run \`baker experiment start --landing ${opts.controlSlug} --variant ${opts.variantSlug} --because "\u2026" --change "\u2026"\` to begin, and read the result with \`baker experiment status\`. When the test ends, \`baker experiment fold\` moves the version that won into the page and removes this folder.`,
51969
53043
  ""
51970
53044
  ].join("\n");
51971
53045
  return `${out.trimEnd()}
@@ -51986,16 +53060,16 @@ async function resolveTargets(projectRoot, opts) {
51986
53060
  `"${controlSlug}" is already a variant of "${variantOfSlug(controlSlug)}". Make the next variant of the page itself: \`baker landing variant ${variantOfSlug(controlSlug)}\`.`
51987
53061
  );
51988
53062
  }
51989
- const controlDir = path39.resolve(projectRoot, "src", "pages", controlSlug);
53063
+ const controlDir = path41.resolve(projectRoot, "src", "pages", controlSlug);
51990
53064
  if (!await isDir3(controlDir)) {
51991
53065
  fail7("NOT_FOUND", `No page at src/pages/${controlSlug}/`, {
51992
53066
  availableSlugs: await listLandingSlugs2(projectRoot)
51993
53067
  });
51994
53068
  }
51995
- if (!await exists(path39.join(controlDir, "index.astro"))) {
53069
+ if (!await exists(path41.join(controlDir, "index.astro"))) {
51996
53070
  fail7("NOT_FOUND", `src/pages/${controlSlug}/index.astro is missing, so there is no page to make a variant of.`);
51997
53071
  }
51998
- const internalId = await readInternalId(path39.join(controlDir, "_definition.md"));
53072
+ const internalId = await readInternalId(path41.join(controlDir, "_definition.md"));
51999
53073
  const variantSlugValue = variantSlug(
52000
53074
  controlSlug,
52001
53075
  nextVariantNumber(
@@ -52004,10 +53078,10 @@ async function resolveTargets(projectRoot, opts) {
52004
53078
  await archivedVariantNumbers(projectRoot, internalId)
52005
53079
  )
52006
53080
  );
52007
- const variantDir = path39.resolve(projectRoot, "src", "pages", variantSlugValue);
52008
- const available = await listComponents(path39.join(controlDir, "_components"));
53081
+ const variantDir = path41.resolve(projectRoot, "src", "pages", variantSlugValue);
53082
+ const available = await listComponents(path41.join(controlDir, "_components"));
52009
53083
  const forks = [];
52010
- for (const name of parseForks(opts.fork)) {
53084
+ for (const name of parseForks(opts.fork, opts.rawArgs)) {
52011
53085
  const resolved = resolveForkName(name, available);
52012
53086
  if (resolved === null) {
52013
53087
  fail7("NOT_FOUND", `"${name}" is not a component of ${controlSlug}.`, {
@@ -52021,26 +53095,26 @@ async function resolveTargets(projectRoot, opts) {
52021
53095
  }
52022
53096
  async function writeVariant(opts) {
52023
53097
  const { controlDir, variantDir, controlSlug, variantSlug: variantSlug2, forks } = opts;
52024
- const forkedTargets = new Set(forks.map((f) => path39.join(controlDir, "_components", f)));
53098
+ const forkedTargets = new Set(forks.map((f) => path41.join(controlDir, "_components", f)));
52025
53099
  const written = [];
52026
- const indexText = await readFile25(path39.join(controlDir, "index.astro"), "utf8");
53100
+ const indexText = await readFile27(path41.join(controlDir, "index.astro"), "utf8");
52027
53101
  await mkdir13(variantDir, { recursive: true });
52028
53102
  await writeFile18(
52029
- path39.join(variantDir, "index.astro"),
53103
+ path41.join(variantDir, "index.astro"),
52030
53104
  repointFile(indexText, { fromDir: controlDir, toDir: variantDir, controlDir, variantDir, forkedTargets }),
52031
53105
  "utf8"
52032
53106
  );
52033
53107
  written.push(`src/pages/${variantSlug2}/index.astro`);
52034
53108
  for (const fork of forks) {
52035
- const fromFile = path39.join(controlDir, "_components", fork);
52036
- const toFile = path39.join(variantDir, "_components", fork);
52037
- const text2 = await readFile25(fromFile, "utf8");
52038
- await mkdir13(path39.dirname(toFile), { recursive: true });
53109
+ const fromFile = path41.join(controlDir, "_components", fork);
53110
+ const toFile = path41.join(variantDir, "_components", fork);
53111
+ const text2 = await readFile27(fromFile, "utf8");
53112
+ await mkdir13(path41.dirname(toFile), { recursive: true });
52039
53113
  await writeFile18(
52040
53114
  toFile,
52041
53115
  repointFile(text2, {
52042
- fromDir: path39.dirname(fromFile),
52043
- toDir: path39.dirname(toFile),
53116
+ fromDir: path41.dirname(fromFile),
53117
+ toDir: path41.dirname(toFile),
52044
53118
  controlDir,
52045
53119
  variantDir,
52046
53120
  forkedTargets
@@ -52049,9 +53123,9 @@ async function writeVariant(opts) {
52049
53123
  );
52050
53124
  written.push(`src/pages/${variantSlug2}/_components/${fork}`);
52051
53125
  }
52052
- const controlDefinitionPath = path39.join(controlDir, "_definition.md");
53126
+ const controlDefinitionPath = path41.join(controlDir, "_definition.md");
52053
53127
  if (await exists(controlDefinitionPath)) {
52054
- const definition = buildVariantDefinition(await readFile25(controlDefinitionPath, "utf8"), {
53128
+ const definition = buildVariantDefinition(await readFile27(controlDefinitionPath, "utf8"), {
52055
53129
  internalId: randomUUID2().replace(/-/g, "").slice(0, 8),
52056
53130
  variantSlug: variantSlug2,
52057
53131
  controlSlug,
@@ -52059,11 +53133,11 @@ async function writeVariant(opts) {
52059
53133
  ...opts.because ? { because: opts.because } : {},
52060
53134
  ...opts.change ? { change: opts.change } : {}
52061
53135
  });
52062
- await writeFile18(path39.join(variantDir, "_definition.md"), definition, "utf8");
53136
+ await writeFile18(path41.join(variantDir, "_definition.md"), definition, "utf8");
52063
53137
  written.push(`src/pages/${variantSlug2}/_definition.md`);
52064
53138
  }
52065
- await mkdir13(path39.join(variantDir, "_images"), { recursive: true });
52066
- await writeFile18(path39.join(variantDir, "_images", ".gitkeep"), "", "utf8");
53139
+ await mkdir13(path41.join(variantDir, "_images"), { recursive: true });
53140
+ await writeFile18(path41.join(variantDir, "_images", ".gitkeep"), "", "utf8");
52067
53141
  return written;
52068
53142
  }
52069
53143
  var variantCommand = defineCommand174({
@@ -52088,13 +53162,28 @@ var variantCommand = defineCommand174({
52088
53162
  description: "What is different about this version, in the client's words \u2014 \u201Cput the booking form in the hero\u201D."
52089
53163
  }
52090
53164
  },
52091
- async run({ args }) {
53165
+ async run({ args, rawArgs }) {
52092
53166
  const projectRoot = process.cwd();
52093
53167
  const controlSlug = String(args.page);
52094
53168
  const { controlDir, variantDir, variantSlug: variantSlug2, available, forks } = await resolveTargets(projectRoot, {
52095
53169
  controlSlug,
52096
- fork: args.fork
53170
+ fork: args.fork,
53171
+ rawArgs
52097
53172
  });
53173
+ let status;
53174
+ try {
53175
+ const response = await apiPost("/api/experiments/status", {
53176
+ landingSlug: controlSlug
53177
+ });
53178
+ status = response.data;
53179
+ } catch (error) {
53180
+ fail7(
53181
+ "UNAVAILABLE",
53182
+ `Could not check whether ${controlSlug} is already in a test (${error instanceof Error ? error.message : String(error)}). Nothing was written \u2014 run \`baker experiment status --landing ${controlSlug}\` and try again.`
53183
+ );
53184
+ }
53185
+ const taken = pageInTest(status, controlSlug);
53186
+ if (taken) fail7("CONFLICT", taken, { landing: controlSlug });
52098
53187
  const written = await writeVariant({
52099
53188
  controlDir,
52100
53189
  variantDir,
@@ -54070,8 +55159,8 @@ var listCommand16 = defineCommand193({
54070
55159
  });
54071
55160
 
54072
55161
  // src/commands/scheduled-actions/templates.ts
54073
- import { readFile as readFile26 } from "fs/promises";
54074
- import path40 from "path";
55162
+ import { readFile as readFile28 } from "fs/promises";
55163
+ import path42 from "path";
54075
55164
  import { defineCommand as defineCommand194 } from "citty";
54076
55165
  registerSchema({
54077
55166
  command: "scheduled-actions.templates",
@@ -54174,7 +55263,7 @@ Full guide: __tooling__/docs/tools/baker/scheduled-actions.md`
54174
55263
  }
54175
55264
  if (save.length > 0) {
54176
55265
  const briefFile = flag("brief-file");
54177
- const brief = briefFile.length > 0 ? await readFile26(path40.resolve(briefFile), "utf8") : flag("brief");
55266
+ const brief = briefFile.length > 0 ? await readFile28(path42.resolve(briefFile), "utf8") : flag("brief");
54178
55267
  if (brief.trim().length === 0) {
54179
55268
  failValidation4("--brief-file (preferred) or --brief is required: the brief is the recipe.");
54180
55269
  }
@@ -54295,7 +55384,7 @@ registerSchema({
54295
55384
  prompt: { type: "string", description: "Replacement additional spawned-agent instructions", required: false }
54296
55385
  }
54297
55386
  });
54298
- var updateCommand3 = defineCommand196({
55387
+ var updateCommand4 = defineCommand196({
54299
55388
  meta: {
54300
55389
  name: "update",
54301
55390
  description: "Stage a scheduled action update. Examples: baker scheduled-actions update <id> --enabled false | baker scheduled-actions update <id> --mode publish"
@@ -54394,7 +55483,7 @@ Full guide: __tooling__/docs/tools/baker/scheduled-actions.md`
54394
55483
  list: listCommand16,
54395
55484
  get: getCommand4,
54396
55485
  create: createCommand3,
54397
- update: updateCommand3,
55486
+ update: updateCommand4,
54398
55487
  delete: deleteCommand3,
54399
55488
  templates: templatesCommand,
54400
55489
  trigger: triggerCommand
@@ -54645,7 +55734,7 @@ function parseImageRefs(spec) {
54645
55734
  }
54646
55735
  var defaultDeps = {
54647
55736
  ingest: (url) => apiPost("/api/images/ingest", { url, source: "uploaded" }),
54648
- upload: (path42) => uploadLocalImage({ file: path42, contentType: detectImageContentType(path42), source: "uploaded" })
55737
+ upload: (path44) => uploadLocalImage({ file: path44, contentType: detectImageContentType(path44), source: "uploaded" })
54649
55738
  };
54650
55739
  async function resolveLibraryImageIds(spec, limit, deps = defaultDeps) {
54651
55740
  const refs = parseImageRefs(spec);
@@ -54665,10 +55754,10 @@ async function resolveLibraryImageIds(spec, limit, deps = defaultDeps) {
54665
55754
  }
54666
55755
  return { imageIds, added };
54667
55756
  }
54668
- function uploadFailure(path42) {
55757
+ function uploadFailure(path44) {
54669
55758
  return (error) => {
54670
55759
  if (error instanceof ApiError) throw error;
54671
- throw new ApiError("VALIDATION_ERROR", `Could not read "${path42}" as an image.`);
55760
+ throw new ApiError("VALIDATION_ERROR", `Could not read "${path44}" as an image.`);
54672
55761
  };
54673
55762
  }
54674
55763
 
@@ -55666,7 +56755,7 @@ import { defineCommand as defineCommand211 } from "citty";
55666
56755
  import { defineCommand as defineCommand208 } from "citty";
55667
56756
 
55668
56757
  // src/commands/tag-manager/shared.ts
55669
- import { readFileSync as readFileSync17 } from "fs";
56758
+ import { readFileSync as readFileSync18 } from "fs";
55670
56759
  function failValidation5(message) {
55671
56760
  writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message } });
55672
56761
  process.exit(1);
@@ -55682,7 +56771,7 @@ function requireTarget6(args, entity) {
55682
56771
  function loadJsonArg2(args, flag = "json") {
55683
56772
  const inline = args[flag];
55684
56773
  const file = args.file;
55685
- const raw = typeof file === "string" && file.length > 0 ? readFileSync17(file, "utf8") : typeof inline === "string" && inline.length > 0 ? inline : void 0;
56774
+ const raw = typeof file === "string" && file.length > 0 ? readFileSync18(file, "utf8") : typeof inline === "string" && inline.length > 0 ? inline : void 0;
55686
56775
  if (raw === void 0) {
55687
56776
  failValidation5(`pass --${flag} with inline JSON or --file with a path to a JSON file`);
55688
56777
  }
@@ -55730,10 +56819,10 @@ async function stageOp4(op) {
55730
56819
  handleError5(err);
55731
56820
  }
55732
56821
  }
55733
- async function draftAction3(path42, body, chat) {
56822
+ async function draftAction3(path44, body, chat) {
55734
56823
  const chatId = resolveChatId(chat);
55735
56824
  try {
55736
- const data = await apiPost(path42, { chatId, ...body });
56825
+ const data = await apiPost(path44, { chatId, ...body });
55737
56826
  writeJsonEnvelope({ ok: true, data });
55738
56827
  return data;
55739
56828
  } catch (err) {
@@ -56868,9 +57957,9 @@ var groupCommand2 = defineCommand219({
56868
57957
  });
56869
57958
 
56870
57959
  // src/commands/videos/ingest.ts
56871
- import { mkdtemp as mkdtemp2, rm as rm8, stat as stat9 } from "fs/promises";
57960
+ import { mkdtemp as mkdtemp2, rm as rm8, stat as stat10 } from "fs/promises";
56872
57961
  import { tmpdir as tmpdir3 } from "os";
56873
- import path41 from "path";
57962
+ import path43 from "path";
56874
57963
  import { defineCommand as defineCommand220 } from "citty";
56875
57964
 
56876
57965
  // src/lib/streamUpload.ts
@@ -57221,7 +58310,7 @@ function ingestUrl(args) {
57221
58310
  }
57222
58311
  async function downloadThenIngest(args, country) {
57223
58312
  const vimeoCookie = captureVimeoCookie();
57224
- const workDir = await mkdtemp2(path41.join(tmpdir3(), "videos-ingest-"));
58313
+ const workDir = await mkdtemp2(path43.join(tmpdir3(), "videos-ingest-"));
57225
58314
  try {
57226
58315
  const probe = await probeYtDlp({ url: args.url, country, vimeoCookie, cookieDir: workDir });
57227
58316
  if (isAudioOnly(probe.info)) {
@@ -57252,7 +58341,7 @@ async function downloadThenIngest(args, country) {
57252
58341
  // we allow to fetch it cannot drift apart.
57253
58342
  timeoutMs: downloadTimeoutMs(durationSeconds(probe.info))
57254
58343
  });
57255
- const stats = await stat9(filePath);
58344
+ const stats = await stat10(filePath);
57256
58345
  if (stats.size > MAX_VIDEO_INGEST_BYTES) {
57257
58346
  throw new ApiError(
57258
58347
  "VALIDATION_ERROR",
@@ -57357,7 +58446,7 @@ var searchCommand4 = defineCommand221({
57357
58446
  var tagsCommand6 = makeTagsCommand("videos", "video", "/api/videos/tags");
57358
58447
 
57359
58448
  // src/commands/videos/upload.ts
57360
- import { readFile as readFile27, stat as stat10 } from "fs/promises";
58449
+ import { readFile as readFile29, stat as stat11 } from "fs/promises";
57361
58450
  import { basename as basename3, extname as extname4 } from "path";
57362
58451
  import { defineCommand as defineCommand222 } from "citty";
57363
58452
  var MIME_MAP = {
@@ -57441,7 +58530,7 @@ var uploadCommand2 = defineCommand222({
57441
58530
  const originalFilename = basename3(filePath);
57442
58531
  const descriptionContext = args.context;
57443
58532
  if (args["dry-run"]) {
57444
- const fileStats = await stat10(filePath);
58533
+ const fileStats = await stat11(filePath);
57445
58534
  writeJson({
57446
58535
  ok: true,
57447
58536
  dryRun: true,
@@ -57454,7 +58543,7 @@ var uploadCommand2 = defineCommand222({
57454
58543
  originalFilename,
57455
58544
  descriptionContext
57456
58545
  });
57457
- const fileBuffer = await readFile27(filePath);
58546
+ const fileBuffer = await readFile29(filePath);
57458
58547
  const uploadResponse = await fetch(uploadUrl, {
57459
58548
  method: "PUT",
57460
58549
  headers: { "Content-Type": contentType },
@@ -58841,7 +59930,7 @@ function unknownFlagEnvelope(unknown, commandPath, suggestion) {
58841
59930
  };
58842
59931
  }
58843
59932
  function commandPathOf(root, argv) {
58844
- const path42 = [];
59933
+ const path44 = [];
58845
59934
  let command = root;
58846
59935
  for (const token of argv) {
58847
59936
  if (token === "--" || token.startsWith("-")) {
@@ -58852,10 +59941,10 @@ function commandPathOf(root, argv) {
58852
59941
  if (next === void 0 || typeof next !== "object") {
58853
59942
  break;
58854
59943
  }
58855
- path42.push(token);
59944
+ path44.push(token);
58856
59945
  command = next;
58857
59946
  }
58858
- return path42.join(" ");
59947
+ return path44.join(" ");
58859
59948
  }
58860
59949
  function refuseUnknownFlags(root, argv) {
58861
59950
  const unknown = findUnknownFlags(root, argv);
@@ -58873,7 +59962,7 @@ function refuseUnknownFlags(root, argv) {
58873
59962
  }
58874
59963
 
58875
59964
  // src/version.ts
58876
- import { readFileSync as readFileSync18 } from "fs";
59965
+ import { readFileSync as readFileSync19 } from "fs";
58877
59966
  function packageJsonUrl() {
58878
59967
  return new URL("../package.json", import.meta.url);
58879
59968
  }
@@ -58885,7 +59974,7 @@ function parsePackageVersion(raw) {
58885
59974
  throw new Error("Invalid CLI package.json: missing version");
58886
59975
  }
58887
59976
  function getCliVersion() {
58888
- return parsePackageVersion(readFileSync18(packageJsonUrl(), "utf8"));
59977
+ return parsePackageVersion(readFileSync19(packageJsonUrl(), "utf8"));
58889
59978
  }
58890
59979
 
58891
59980
  // src/cli.ts
@@ -58924,7 +60013,7 @@ Introspection: Run 'baker schema <command>' to inspect argument schemas.`
58924
60013
  tags: tagsCommand4,
58925
60014
  "tag-manager": tagManagerCommand,
58926
60015
  chats: chatsCommand,
58927
- history: historyCommand,
60016
+ history: historyCommand2,
58928
60017
  hubspot: hubspotCommand,
58929
60018
  "winning-ads": winningAdsCommand,
58930
60019
  mcp: mcpCommand,