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

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
@@ -38796,53 +38796,32 @@ async function foldExperiment(root, target) {
38796
38796
  };
38797
38797
  }
38798
38798
 
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
38799
  // src/commands/experiment/hints.ts
38824
- var RUNNING_HINT = {
38800
+ var LIVE_HINT = {
38825
38801
  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.`
38802
+ 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.`,
38803
+ 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.`,
38804
+ stopped_early_harmful: (row) => `${row.experimentId} is doing damage and is STILL being shown to half the visitors. Run \`baker experiment finish --id ${row.experimentId} --keep original\` now.`,
38805
+ // Unreachable on a live test — only ending one produces it. The Record is
38806
+ // exhaustive by type so a verdict added upstream fails to compile here rather
38807
+ // than printing nothing.
38808
+ inconclusive: (row) => `${row.experimentId} was ended before it concluded and found nothing.`,
38809
+ 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
38810
  };
38835
38811
  function describeWait(row) {
38836
38812
  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.";
38813
+ return "This page's traffic will not settle it at any point, so end it with `--keep original` and test a bigger change.";
38838
38814
  }
38839
38815
  if (row.earliestDecisionAt === null) {
38840
38816
  return "This page has produced no visitors yet, so there is nothing to project a finish date from.";
38841
38817
  }
38842
38818
  return `Earliest it could say anything: ${new Date(row.earliestDecisionAt).toISOString().slice(0, 10)}.`;
38843
38819
  }
38820
+ function pausedNote(row) {
38821
+ 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. ` : "";
38822
+ }
38844
38823
  function buildStatusHints(experiments) {
38845
- const hints2 = experiments.filter((row) => row.status === "running").map((row) => RUNNING_HINT[row.verdict](row));
38824
+ const hints2 = experiments.filter((row) => row.status !== "finished").map((row) => pausedNote(row) + LIVE_HINT[row.verdict](row));
38846
38825
  if (experiments.length === 0) {
38847
38826
  hints2.push(
38848
38827
  "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 +38832,48 @@ function buildStatusHints(experiments) {
38853
38832
  function asLift(value) {
38854
38833
  return `+${Math.round(value * 100)}%`;
38855
38834
  }
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.`;
38835
+ function horizonsHint(plan) {
38836
+ const settled = plan.horizons.filter((horizon) => horizon.detectableLift !== null);
38837
+ if (settled.length === 0) {
38838
+ 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.";
38860
38839
  }
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.`;
38840
+ const parts = settled.map((horizon) => `${horizon.days} days \u2192 ${asLift(horizon.detectableLift ?? 0)}`);
38841
+ return `The smallest change each window could settle on this page's traffic: ${parts.join(", ")}. Design the alternative to be at least that different.`;
38862
38842
  }
38863
- function buildPlanHints(plan, days) {
38864
- const window2 = windowHint(days, plan.detectableLiftInDays);
38843
+ function conversionsHint(plan) {
38844
+ if (plan.conversions.length <= 1) return null;
38845
+ const others = plan.conversions.filter((entry) => entry.name !== plan.goal);
38846
+ return `This page's visitors also went on to: ${others.map((entry) => `\u201C${entry.name}\u201D (${entry.convertingVisitors})`).join(", ")}. They are reported beside the goal on every result, and decide nothing.`;
38847
+ }
38848
+ function buildPlanHints(plan) {
38849
+ const horizons = horizonsHint(plan);
38865
38850
  if (!plan.canRun) {
38866
38851
  return [
38867
38852
  plan.reason ?? "This test cannot run on this page.",
38868
- ...window2 ? [window2] : [],
38853
+ ...horizons && plan.visitors > 0 ? [horizons] : [],
38869
38854
  "A test that cannot conclude is worse than no test \u2014 it still produces a number, and somebody acts on it."
38870
38855
  ];
38871
38856
  }
38872
38857
  const hints2 = [
38873
- `This test measures ${plan.goalLabel}. That is fixed when it starts and cannot be changed later.`,
38858
+ `This test is read on \u201C${plan.goal}\u201D. That is fixed when it starts and cannot be changed later.`,
38874
38859
  // Fires at the exact moment the hypothesis gets invented, whether or not
38875
38860
  // the agent read the family doc. `plan` says a question CAN be settled here
38876
38861
  // and says nothing about which question is worth asking — and a well-run
38877
38862
  // 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.",
38863
+ "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
38864
  // The base always works, so a missing integration is never a reason to
38880
38865
  // stop. Naming what it would have shown is worth more to the client than
38881
38866
  // either a silent gap or a run that waited.
38882
38867
  "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
38868
  ];
38869
+ const conversions = conversionsHint(plan);
38870
+ if (conversions) hints2.push(conversions);
38884
38871
  if (plan.estimatedDays !== null && plan.estimatedDays > 60) {
38885
38872
  hints2.push(
38886
38873
  `At this page's traffic it would take about ${plan.estimatedDays} days. Consider testing a bigger change, which needs less traffic to detect.`
38887
38874
  );
38888
38875
  }
38889
- if (window2) hints2.push(window2);
38876
+ if (horizons) hints2.push(horizons);
38890
38877
  return hints2;
38891
38878
  }
38892
38879
 
@@ -38901,45 +38888,7 @@ var experimentCompositionSchema = z35.object({
38901
38888
 
38902
38889
  // ../api/src/experiments/goal.ts
38903
38890
  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
- ]);
38891
+ var experimentGoalSchema = z36.string().trim().min(1).max(MAX_CONVERSION_NAME);
38943
38892
 
38944
38893
  // ../api/src/experiments/hypothesis.ts
38945
38894
  import { z as z37 } from "zod";
@@ -38997,6 +38946,14 @@ var slugSchema = z38.string().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/
38997
38946
  var experimentPlanRequestSchema = z38.object({
38998
38947
  landingSlug: slugSchema,
38999
38948
  variantSlug: slugSchema,
38949
+ /**
38950
+ * Which of the company's conversions the test is about.
38951
+ *
38952
+ * Optional only when the company counts exactly one outcome, in which case
38953
+ * it is that one. A company counting several has to say which — a test that
38954
+ * "measures conversions" on a page with a newsletter box and a demo request
38955
+ * is measuring two things and deciding on their sum.
38956
+ */
39000
38957
  goal: experimentGoalSchema.optional(),
39001
38958
  /**
39002
38959
  * The smallest lift worth detecting, relative to the baseline. `0.2` is +20%.
@@ -39006,18 +38963,7 @@ var experimentPlanRequestSchema = z38.object({
39006
38963
  * quarter of a million visitors per arm; `plan` will say so rather than
39007
38964
  * running it.
39008
38965
  */
39009
- minDetectableRelativeLift: z38.number().gt(0).max(10).optional(),
39010
- /**
39011
- * "I have this many days — what could I even see?"
39012
- *
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.
39019
- */
39020
- days: z38.number().int().gt(0).max(365).optional()
38966
+ minDetectableRelativeLift: z38.number().gt(0).max(10).optional()
39021
38967
  });
39022
38968
  var experimentStartRequestSchema = experimentPlanRequestSchema.extend({
39023
38969
  hypothesis: experimentHypothesisSchema,
@@ -39032,28 +38978,13 @@ var experimentStartRequestSchema = experimentPlanRequestSchema.extend({
39032
38978
  */
39033
38979
  composition: experimentCompositionSchema.optional()
39034
38980
  });
38981
+ var experimentKeepSchema = z38.enum(["original", "variant"]);
39035
38982
  var experimentFinishRequestSchema = z38.object({
39036
38983
  experimentId: z38.string().min(1).max(64),
39037
- /**
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.
39055
- */
39056
- abandon: z38.boolean().optional()
38984
+ keep: experimentKeepSchema
38985
+ });
38986
+ var experimentPauseRequestSchema = z38.object({
38987
+ experimentId: z38.string().min(1).max(64)
39057
38988
  });
39058
38989
  var experimentFoldRequestSchema = z38.object({
39059
38990
  /** Which test to fold. Omitted ⇒ every fold this company owes. */
@@ -39081,10 +39012,26 @@ var experimentStatusRequestSchema = z38.object({
39081
39012
  experimentId: z38.string().max(64).optional(),
39082
39013
  full: z38.boolean().optional()
39083
39014
  });
39015
+ var experimentPageConversionSchema = z38.object({
39016
+ name: z38.string(),
39017
+ /** Distinct visitors to the page who went on to do this. */
39018
+ convertingVisitors: z38.number()
39019
+ });
39020
+ var experimentHorizonSchema = z38.object({
39021
+ days: z38.number(),
39022
+ /** The smallest relative lift detectable in that many days. `null` when none would be. */
39023
+ detectableLift: z38.number().nullable()
39024
+ });
39084
39025
  var experimentPlanResponseSchema = z38.object({
39085
39026
  canRun: z38.boolean(),
39086
39027
  /** Present when `canRun` is false. Product language, ready to show. */
39087
39028
  reason: z38.string().optional(),
39029
+ /** The conversion the test would be read on. Absent when none could be chosen. */
39030
+ goal: experimentGoalSchema.optional(),
39031
+ /** Every conversion this page produced over the sizing window, most common first. */
39032
+ conversions: z38.array(experimentPageConversionSchema),
39033
+ /** Distinct visitors the page had over the sizing window. */
39034
+ visitors: z38.number(),
39088
39035
  baselineRate: z38.number(),
39089
39036
  minDetectableRelativeLift: z38.number(),
39090
39037
  /** `null` when no amount of traffic could settle the hypothesis. */
@@ -39093,14 +39040,7 @@ var experimentPlanResponseSchema = z38.object({
39093
39040
  dailyVisitorsPerVariant: z38.number(),
39094
39041
  /** `null` when the page has no traffic to project from. */
39095
39042
  estimatedDays: z38.number().nullable(),
39096
- goal: experimentGoalSchema,
39097
- goalLabel: z38.string(),
39098
- /**
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.
39102
- */
39103
- detectableLiftInDays: z38.number().nullable().optional()
39043
+ horizons: z38.array(experimentHorizonSchema)
39104
39044
  });
39105
39045
  var experimentVerdictSchema = z38.enum([
39106
39046
  "keep_running",
@@ -39109,16 +39049,17 @@ var experimentVerdictSchema = z38.enum([
39109
39049
  "invalid",
39110
39050
  "stopped_early_harmful",
39111
39051
  /**
39112
- * Stopped by a person before it concluded. Never produced by the stopping
39113
- * rule — only by `finish --abandon`.
39052
+ * Ended before the numbers could say anything. Never produced by the
39053
+ * stopping rule — only by ending a `keep_running` test.
39114
39054
  *
39115
39055
  * Its own verdict rather than folded into `no_difference`, because they are
39116
39056
  * opposite claims: one says the two versions were measured and found alike,
39117
39057
  * the other says nobody ever found out. Collapsing them would manufacture the
39118
39058
  * exact finding this feature exists to refuse.
39119
39059
  */
39120
- "abandoned"
39060
+ "inconclusive"
39121
39061
  ]);
39062
+ var experimentStatusValueSchema = z38.enum(["running", "paused", "finished"]);
39122
39063
  var experimentArmSchema = z38.object({
39123
39064
  slug: z38.string(),
39124
39065
  visitors: z38.number(),
@@ -39134,6 +39075,16 @@ var experimentArmSchema = z38.object({
39134
39075
  */
39135
39076
  previewUrl: z38.string().nullable().optional()
39136
39077
  });
39078
+ var experimentMetricSchema = z38.object({
39079
+ name: z38.string(),
39080
+ control: z38.object({ conversions: z38.number(), rate: z38.number() }),
39081
+ variant: z38.object({ conversions: z38.number(), rate: z38.number() }),
39082
+ relativeLift: z38.number(),
39083
+ /** The anytime-valid interval on the relative lift. */
39084
+ liftInterval: z38.tuple([z38.number(), z38.number()]),
39085
+ /** `better` when the interval sits above zero, `worse` below it, `unclear` across it. */
39086
+ reading: z38.enum(["better", "worse", "unclear"])
39087
+ });
39137
39088
  var experimentTimelineArmSchema = z38.object({
39138
39089
  visitors: z38.number(),
39139
39090
  conversions: z38.number(),
@@ -39173,19 +39124,29 @@ var experimentStatusRowSchema = z38.object({
39173
39124
  * is not a hypothesis, "leads should go up by at least 20%" is.
39174
39125
  */
39175
39126
  minDetectableRelativeLift: z38.number(),
39176
- goalLabel: z38.string(),
39177
- status: z38.enum(["running", "finished"]),
39127
+ /** The conversion the verdict is read from. The company's own name for it. */
39128
+ goal: experimentGoalSchema,
39129
+ status: experimentStatusValueSchema,
39178
39130
  startedAt: z38.number(),
39131
+ /** Set while paused. The split is off and everyone sees the original. */
39132
+ pausedAt: z38.number().nullable(),
39179
39133
  finishedAt: z38.number().nullable(),
39180
39134
  verdict: experimentVerdictSchema,
39181
- /** Set only on `winner`. Which page to keep. */
39135
+ /** Set only on `winner`. Which version the numbers favour. */
39182
39136
  winner: z38.enum(["control", "variant"]).nullable(),
39137
+ /**
39138
+ * Which version is live, once the test has ended. Chosen by whoever ended it,
39139
+ * and allowed to disagree with `winner` — the card says so when it does.
39140
+ */
39141
+ survivor: z38.enum(["control", "variant"]).nullable(),
39183
39142
  /** Set only on `invalid`. Why the numbers cannot be read. */
39184
39143
  invalidReason: z38.enum(["sample_ratio_mismatch", "crossover"]).nullable(),
39185
39144
  /** What a person should be told, in one sentence, in product language. */
39186
39145
  summary: z38.string(),
39187
39146
  control: experimentArmSchema,
39188
39147
  variant: experimentArmSchema,
39148
+ /** Every other conversion the company counts, on the same two arms. */
39149
+ secondary: z38.array(experimentMetricSchema),
39189
39150
  /**
39190
39151
  * How close this test is to being able to say anything at all, 0 to 1.
39191
39152
  *
@@ -39199,8 +39160,8 @@ var experimentStatusRowSchema = z38.object({
39199
39160
  earliestDecisionAt: z38.number().nullable(),
39200
39161
  /**
39201
39162
  * 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.
39163
+ * A different thing from a page that is merely slow, and the reason to end
39164
+ * a test rather than leave it running.
39204
39165
  */
39205
39166
  willNotConclude: z38.boolean(),
39206
39167
  /**
@@ -39225,6 +39186,13 @@ var experimentStatusRowSchema = z38.object({
39225
39186
  var experimentStatusResponseSchema = z38.object({
39226
39187
  experiments: z38.array(experimentStatusRowSchema)
39227
39188
  });
39189
+ var experimentFinishResponseSchema = z38.object({
39190
+ verdict: experimentVerdictSchema,
39191
+ survivor: z38.enum(["control", "variant"]),
39192
+ /** True when `keep` went against a verdict the numbers had reached. */
39193
+ againstVerdict: z38.boolean(),
39194
+ summary: z38.string()
39195
+ });
39228
39196
 
39229
39197
  // src/commands/experiment/hypothesis.ts
39230
39198
  var SOURCES = experimentEvidenceSourceSchema.options.join(", ");
@@ -39262,7 +39230,7 @@ function parseEvidence(raw) {
39262
39230
  // src/commands/experiment/index.ts
39263
39231
  var GOAL_ARG = {
39264
39232
  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.",
39233
+ description: "Which of this company's conversions the test is about, by the name on the Conversions screen \u2014 \u201CBooked a call\u201D, \u201CQuote requested\u201D. Fixed for the life of the test. Optional when the company counts exactly one outcome; required when it counts several, and `plan` lists them. Every other conversion is reported beside the goal on every result and decides nothing. If the outcome you need is not defined yet, define it first with `baker analytics conversions` \u2014 it applies to history, so no traffic is lost.",
39266
39234
  required: false
39267
39235
  };
39268
39236
  var LIFT_ARG = {
@@ -39270,11 +39238,6 @@ var LIFT_ARG = {
39270
39238
  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.",
39271
39239
  required: false
39272
39240
  };
39273
- var DAYS_ARG = {
39274
- 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.",
39276
- required: false
39277
- };
39278
39241
  var BECAUSE_ARG = {
39279
39242
  type: "string",
39280
39243
  description: "What you SAW that makes this worth testing \u2014 the observation, not the change. \u201C60% of visitors never scroll past the hero\u201D, \u201CCPL doubled after the new headline\u201D. This is the field that makes a losing test worth something: without it there is no belief for the result to be evidence against, and the next test is no better informed than this one.",
@@ -39295,6 +39258,7 @@ var EVIDENCE_ARG = {
39295
39258
  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
39259
  required: false
39297
39260
  };
39261
+ var ID_ARG = { type: "string", description: "The test, by its id", required: true };
39298
39262
  function fail5(message) {
39299
39263
  writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message } });
39300
39264
  process.exit(1);
@@ -39311,40 +39275,31 @@ registerSchema({
39311
39275
  description: "Whether a page has enough traffic to settle a question, before anything is built",
39312
39276
  args: {
39313
39277
  landing: { type: "string", description: "The page under test, by slug", required: true },
39314
- variant: { type: "string", description: "The alternative page's slug", required: true },
39278
+ variant: { type: "string", description: "The new variant's slug, `<page>--<n>`", required: true },
39315
39279
  goal: GOAL_ARG,
39316
- lift: LIFT_ARG,
39317
- days: DAYS_ARG
39280
+ lift: LIFT_ARG
39318
39281
  }
39319
39282
  });
39320
39283
  var planCommand = defineCommand119({
39321
39284
  meta: {
39322
39285
  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."
39286
+ description: "Start here, BEFORE building the alternative page. Says whether this page gets enough traffic to settle the question, how long it would take, which conversions the page's visitors actually go on to do, and what a 14, 28, 56 or 90-day window could settle. 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."
39324
39287
  },
39325
39288
  args: {
39326
39289
  landing: { type: "string", description: "The page under test, by slug", required: true },
39327
- variant: { type: "string", description: "The alternative page's slug", required: true },
39290
+ variant: { type: "string", description: "The new variant's slug, `<page>--<n>`", required: true },
39328
39291
  goal: GOAL_ARG,
39329
- lift: LIFT_ARG,
39330
- days: DAYS_ARG
39292
+ lift: LIFT_ARG
39331
39293
  },
39332
39294
  run: async ({ args }) => {
39333
- const goal = parseGoal(args.goal ? String(args.goal) : void 0);
39334
- if (goal && "error" in goal) fail5(goal.error);
39335
39295
  try {
39336
39296
  const response = await apiPost("/api/experiments/plan", {
39337
39297
  landingSlug: String(args.landing),
39338
39298
  variantSlug: String(args.variant),
39339
- ...goal ? { goal } : {},
39340
- ...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)
39299
+ ...args.goal ? { goal: String(args.goal) } : {},
39300
+ ...args.lift ? { minDetectableRelativeLift: Number(args.lift) } : {}
39347
39301
  });
39302
+ writeJsonEnvelope({ ok: true, data: response.data, hints: buildPlanHints(response.data) });
39348
39303
  } catch (error) {
39349
39304
  reportApiError(error);
39350
39305
  }
@@ -39352,10 +39307,10 @@ var planCommand = defineCommand119({
39352
39307
  });
39353
39308
  registerSchema({
39354
39309
  command: "experiment.start",
39355
- description: "Stage an A/B test between a page and its alternative",
39310
+ description: "Stage an A/B test between a page and a new variant of it",
39356
39311
  args: {
39357
39312
  landing: { type: "string", description: "The page under test, by slug", required: true },
39358
- variant: { type: "string", description: "The alternative page's slug", required: true },
39313
+ variant: { type: "string", description: "The new variant's slug, `<page>--<n>`", required: true },
39359
39314
  because: BECAUSE_ARG,
39360
39315
  change: CHANGE_ARG,
39361
39316
  expect: EXPECT_ARG,
@@ -39367,11 +39322,11 @@ registerSchema({
39367
39322
  var startCommand = defineCommand119({
39368
39323
  meta: {
39369
39324
  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."
39325
+ description: "Stage a test between the page and its new variant. 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 new variant. Re-runs `plan` first and refuses on the same grounds."
39371
39326
  },
39372
39327
  args: {
39373
39328
  landing: { type: "string", description: "The page under test, by slug", required: true },
39374
- variant: { type: "string", description: "The alternative page's slug", required: true },
39329
+ variant: { type: "string", description: "The new variant's slug, `<page>--<n>`", required: true },
39375
39330
  because: BECAUSE_ARG,
39376
39331
  change: CHANGE_ARG,
39377
39332
  expect: EXPECT_ARG,
@@ -39380,8 +39335,6 @@ var startCommand = defineCommand119({
39380
39335
  lift: LIFT_ARG
39381
39336
  },
39382
39337
  run: async ({ args }) => {
39383
- const goal = parseGoal(args.goal ? String(args.goal) : void 0);
39384
- if (goal && "error" in goal) fail5(goal.error);
39385
39338
  const expect = args.expect === void 0 ? "increase" : String(args.expect);
39386
39339
  if (expect !== "increase" && expect !== "decrease") {
39387
39340
  fail5("`--expect` is either `increase` or `decrease` \u2014 which way should the goal move if you are right?");
@@ -39400,7 +39353,7 @@ var startCommand = defineCommand119({
39400
39353
  ...evidence ? { evidence } : {}
39401
39354
  },
39402
39355
  ...composition ? { composition } : {},
39403
- ...goal ? { goal } : {},
39356
+ ...args.goal ? { goal: String(args.goal) } : {},
39404
39357
  ...args.lift ? { minDetectableRelativeLift: Number(args.lift) } : {}
39405
39358
  });
39406
39359
  writeJsonEnvelope({
@@ -39411,9 +39364,9 @@ var startCommand = defineCommand119({
39411
39364
  // with no `--fork` succeeds and produces a page that renders exactly
39412
39365
  // the control, so the test would run for weeks against itself.
39413
39366
  ...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.`
39367
+ `\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)} --fork Hero.astro\`) and edit only that file.`
39415
39368
  ] : [],
39416
- "Staged. The split starts when this session is published \u2014 publishing is the review of the alternative page.",
39369
+ "Staged. The split starts when this session is published \u2014 publishing is the review of the new variant.",
39417
39370
  "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
39371
  ]
39419
39372
  });
@@ -39424,7 +39377,7 @@ var startCommand = defineCommand119({
39424
39377
  });
39425
39378
  registerSchema({
39426
39379
  command: "experiment.status",
39427
- description: "The verdict on every A/B test, running and finished",
39380
+ description: "The verdict on every A/B test, running, paused and finished",
39428
39381
  args: {
39429
39382
  id: { type: "string", description: "One test, by its id", required: false },
39430
39383
  full: { type: "boolean", description: "Include the posteriors behind the verdict", required: false }
@@ -39433,7 +39386,7 @@ registerSchema({
39433
39386
  var statusCommand6 = defineCommand119({
39434
39387
  meta: {
39435
39388
  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."
39389
+ 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
39390
  },
39438
39391
  args: {
39439
39392
  id: { type: "string", description: "One test, by its id", required: false },
@@ -39457,44 +39410,88 @@ var statusCommand6 = defineCommand119({
39457
39410
  });
39458
39411
  registerSchema({
39459
39412
  command: "experiment.finish",
39460
- description: "End a test and send all its traffic to the surviving page",
39413
+ description: "End a test now and send all its traffic to the version you name",
39461
39414
  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
39415
+ id: ID_ARG,
39416
+ keep: {
39417
+ type: "string",
39418
+ 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.",
39419
+ required: true
39467
39420
  }
39468
39421
  }
39469
39422
  });
39470
39423
  var finishCommand = defineCommand119({
39471
39424
  meta: {
39472
39425
  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."
39426
+ 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
39427
  },
39475
39428
  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
39429
+ id: ID_ARG,
39430
+ keep: {
39431
+ type: "string",
39432
+ description: "Which version stays live: `original` or `variant`.",
39433
+ required: true
39481
39434
  }
39482
39435
  },
39483
39436
  run: async ({ args }) => {
39437
+ const keep = String(args.keep);
39438
+ if (keep !== "original" && keep !== "variant") {
39439
+ fail5("`--keep` is either `original` or `variant` \u2014 which version should everyone see from now on?");
39440
+ }
39484
39441
  try {
39485
- const response = await apiPost("/api/experiments/finish", {
39486
- experimentId: String(args.id),
39487
- ...args.abandon === true ? { abandon: true } : {}
39488
- });
39442
+ const response = await apiPost(
39443
+ "/api/experiments/finish",
39444
+ { experimentId: String(args.id), keep }
39445
+ );
39446
+ if ("unstaged" in response.data) {
39447
+ writeJsonEnvelope({
39448
+ ok: true,
39449
+ data: response.data,
39450
+ hints: ["That test had not been published yet, so it was taken back off this session. Nothing was split."]
39451
+ });
39452
+ return;
39453
+ }
39454
+ const data = response.data;
39489
39455
  writeJsonEnvelope({
39490
39456
  ok: true,
39491
- data: response.data,
39457
+ data,
39492
39458
  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.`
39459
+ `Done. Everyone now sees the ${data.survivor === "variant" ? "new variant" : "original"}.`,
39460
+ ...data.againstVerdict ? [
39461
+ `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.`
39462
+ ] : [],
39463
+ ...data.verdict === "keep_running" ? [
39464
+ "This test had not concluded, so it is recorded as inconclusive \u2014 nobody found out. Never report it as no difference."
39496
39465
  ] : [],
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."
39466
+ ...data.survivor === "variant" ? [
39467
+ "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."
39468
+ ] : []
39469
+ ]
39470
+ });
39471
+ } catch (error) {
39472
+ reportApiError(error);
39473
+ }
39474
+ }
39475
+ });
39476
+ registerSchema({
39477
+ command: "experiment.pause",
39478
+ description: "Switch a test's split off \u2014 everyone sees the original until it is resumed",
39479
+ args: { id: ID_ARG }
39480
+ });
39481
+ var pauseCommand = defineCommand119({
39482
+ meta: {
39483
+ name: "pause",
39484
+ 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."
39485
+ },
39486
+ args: { id: ID_ARG },
39487
+ run: async ({ args }) => {
39488
+ try {
39489
+ await apiPost("/api/experiments/pause", { experimentId: String(args.id) });
39490
+ writeJsonEnvelope({
39491
+ ok: true,
39492
+ data: { paused: true },
39493
+ hints: [
39494
+ `Paused. Everyone sees the original page now. \`baker experiment resume --id ${String(args.id)}\` restarts the split with the same sides.`
39498
39495
  ]
39499
39496
  });
39500
39497
  } catch (error) {
@@ -39502,6 +39499,30 @@ var finishCommand = defineCommand119({
39502
39499
  }
39503
39500
  }
39504
39501
  });
39502
+ registerSchema({
39503
+ command: "experiment.resume",
39504
+ description: "Switch a paused test's split back on",
39505
+ args: { id: ID_ARG }
39506
+ });
39507
+ var resumeCommand = defineCommand119({
39508
+ meta: {
39509
+ name: "resume",
39510
+ description: "Switch a paused test's split back on. Visitors already assigned see the same side they saw before."
39511
+ },
39512
+ args: { id: ID_ARG },
39513
+ run: async ({ args }) => {
39514
+ try {
39515
+ await apiPost("/api/experiments/resume", { experimentId: String(args.id) });
39516
+ writeJsonEnvelope({
39517
+ ok: true,
39518
+ data: { resumed: true },
39519
+ hints: ["Resumed. Half the visitors see the new variant again."]
39520
+ });
39521
+ } catch (error) {
39522
+ reportApiError(error);
39523
+ }
39524
+ }
39525
+ });
39505
39526
  registerSchema({
39506
39527
  command: "experiment.fold",
39507
39528
  description: "Make the page's own content the version that won, and archive the one that lost",
@@ -39563,12 +39584,14 @@ var foldCommand = defineCommand119({
39563
39584
  var experimentCommand = defineCommand119({
39564
39585
  meta: {
39565
39586
  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."
39587
+ 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
39588
  },
39568
39589
  subCommands: {
39569
39590
  plan: planCommand,
39570
39591
  start: startCommand,
39571
39592
  status: statusCommand6,
39593
+ pause: pauseCommand,
39594
+ resume: resumeCommand,
39572
39595
  finish: finishCommand,
39573
39596
  fold: foldCommand
39574
39597
  }