@koda-sl/baker-cli 0.328.0 → 0.330.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/dist/cli.js +312 -256
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -578,12 +578,24 @@ var registry = /* @__PURE__ */ new Map();
|
|
|
578
578
|
function registerSchema(schema) {
|
|
579
579
|
registry.set(schema.command, schema);
|
|
580
580
|
}
|
|
581
|
-
function getSchema(command) {
|
|
582
|
-
return registry.get(command);
|
|
583
|
-
}
|
|
584
581
|
function listSchemas() {
|
|
585
582
|
return [...registry.keys()];
|
|
586
583
|
}
|
|
584
|
+
function spellingKey(name) {
|
|
585
|
+
return name.toLowerCase().replaceAll("-", "");
|
|
586
|
+
}
|
|
587
|
+
function resolveSchemaName(words2, names) {
|
|
588
|
+
const joined = words2.join(".");
|
|
589
|
+
if (names.includes(joined)) {
|
|
590
|
+
return joined;
|
|
591
|
+
}
|
|
592
|
+
const matches = names.filter((name) => spellingKey(name) === spellingKey(joined));
|
|
593
|
+
return matches.length === 1 ? matches[0] : void 0;
|
|
594
|
+
}
|
|
595
|
+
function findSchema(words2) {
|
|
596
|
+
const name = resolveSchemaName(words2, listSchemas());
|
|
597
|
+
return name === void 0 ? void 0 : registry.get(name);
|
|
598
|
+
}
|
|
587
599
|
|
|
588
600
|
// ../api/src/actions.ts
|
|
589
601
|
import { z } from "zod";
|
|
@@ -1626,11 +1638,11 @@ var trackingParamsSetSchema = z3.object({
|
|
|
1626
1638
|
message: `${both.join(", ")} is set as both a dynamic and a fixed parameter \u2014 LinkedIn appends each key once, so keep only one`
|
|
1627
1639
|
});
|
|
1628
1640
|
}
|
|
1629
|
-
const
|
|
1630
|
-
if (
|
|
1641
|
+
const count2 = Object.keys(p.dynamicValueParameters ?? {}).length + Object.keys(p.customValueParameters ?? {}).length;
|
|
1642
|
+
if (count2 > LINKEDIN_LIMITS.trackingParams.parametersMax) {
|
|
1631
1643
|
ctx.addIssue({
|
|
1632
1644
|
code: "custom",
|
|
1633
|
-
message: `${
|
|
1645
|
+
message: `${count2} parameters \u2014 keep it under ${LINKEDIN_LIMITS.trackingParams.parametersMax}`
|
|
1634
1646
|
});
|
|
1635
1647
|
}
|
|
1636
1648
|
});
|
|
@@ -1937,8 +1949,8 @@ var leadFormQuestionSchema = z3.object({
|
|
|
1937
1949
|
});
|
|
1938
1950
|
}
|
|
1939
1951
|
if (q.questionType === "MULTIPLE_CHOICE") {
|
|
1940
|
-
const
|
|
1941
|
-
if (
|
|
1952
|
+
const count2 = q.options?.length ?? 0;
|
|
1953
|
+
if (count2 < LEAD.choiceOptionsMin || count2 > LEAD.choiceOptionsMax) {
|
|
1942
1954
|
ctx.addIssue({
|
|
1943
1955
|
code: "custom",
|
|
1944
1956
|
path: ["options"],
|
|
@@ -10672,23 +10684,23 @@ var FALSE_RANGE_PATTERNS = [
|
|
|
10672
10684
|
/\bdesde\s+([^,.;]{3,30}?)\s+hasta\s+([^,.;]{3,30}?)(?=[,.;]|$)/gi
|
|
10673
10685
|
];
|
|
10674
10686
|
function countFalseRanges(text2) {
|
|
10675
|
-
let
|
|
10687
|
+
let count2 = 0;
|
|
10676
10688
|
let first = "";
|
|
10677
10689
|
for (const re2 of FALSE_RANGE_PATTERNS) {
|
|
10678
10690
|
for (const m of text2.matchAll(re2)) {
|
|
10679
10691
|
const left = m[1] ?? "";
|
|
10680
10692
|
const right = m[2] ?? "";
|
|
10681
10693
|
if (/\d/.test(left) || /\d/.test(right)) continue;
|
|
10682
|
-
|
|
10694
|
+
count2++;
|
|
10683
10695
|
if (!first) first = m[0] ?? "";
|
|
10684
10696
|
}
|
|
10685
10697
|
}
|
|
10686
|
-
return { count, first };
|
|
10698
|
+
return { count: count2, first };
|
|
10687
10699
|
}
|
|
10688
10700
|
function countStaccato(text2) {
|
|
10689
10701
|
const sentences = text2.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
|
|
10690
10702
|
let run = 0;
|
|
10691
|
-
let
|
|
10703
|
+
let count2 = 0;
|
|
10692
10704
|
let first = "";
|
|
10693
10705
|
let runStart = 0;
|
|
10694
10706
|
for (const [i, s] of sentences.entries()) {
|
|
@@ -10697,17 +10709,17 @@ function countStaccato(text2) {
|
|
|
10697
10709
|
if (run === 0) runStart = i;
|
|
10698
10710
|
run++;
|
|
10699
10711
|
if (run === STACCATO_RUN) {
|
|
10700
|
-
|
|
10712
|
+
count2++;
|
|
10701
10713
|
if (!first) first = sentences.slice(runStart, i + 1).join(" ");
|
|
10702
10714
|
}
|
|
10703
10715
|
} else {
|
|
10704
10716
|
run = 0;
|
|
10705
10717
|
}
|
|
10706
10718
|
}
|
|
10707
|
-
return { count, first };
|
|
10719
|
+
return { count: count2, first };
|
|
10708
10720
|
}
|
|
10709
10721
|
function countTitleCaseHeadings(text2) {
|
|
10710
|
-
let
|
|
10722
|
+
let count2 = 0;
|
|
10711
10723
|
let first = "";
|
|
10712
10724
|
for (const m of text2.matchAll(/^\s{0,3}#{1,6}\s+(.+)$/gm)) {
|
|
10713
10725
|
const heading = (m[1] ?? "").trim();
|
|
@@ -10715,10 +10727,10 @@ function countTitleCaseHeadings(text2) {
|
|
|
10715
10727
|
if (words2.length < 4) continue;
|
|
10716
10728
|
const capitalized = words2.filter((w) => new RegExp("^\\p{Lu}", "u").test(w)).length;
|
|
10717
10729
|
if (capitalized / words2.length < 0.8) continue;
|
|
10718
|
-
|
|
10730
|
+
count2++;
|
|
10719
10731
|
if (!first) first = heading;
|
|
10720
10732
|
}
|
|
10721
|
-
return { count, first };
|
|
10733
|
+
return { count: count2, first };
|
|
10722
10734
|
}
|
|
10723
10735
|
function countInlineHeaderList(text2) {
|
|
10724
10736
|
const matches = [...text2.matchAll(/^\s*(?:[-*+]|\d+\.)\s+\*\*[^*\n]{2,40}:?\*\*:?\s/gm)];
|
|
@@ -10728,15 +10740,15 @@ function countInlineHeaderList(text2) {
|
|
|
10728
10740
|
var EMOJI = /[\u{1F300}-\u{1FAFF}]|[\u{2600}-\u{27BF}]|[\u{1F000}-\u{1F2FF}]|\u{FE0F}/gu;
|
|
10729
10741
|
var CURLY = /[‘’“”]/g;
|
|
10730
10742
|
function countPhraseTell(text2, id, phrases) {
|
|
10731
|
-
let
|
|
10743
|
+
let count2 = 0;
|
|
10732
10744
|
let first = "";
|
|
10733
10745
|
for (const phrase of phrases) {
|
|
10734
10746
|
for (const m of text2.matchAll(matcherFor(id, phrase))) {
|
|
10735
|
-
|
|
10747
|
+
count2++;
|
|
10736
10748
|
if (!first) first = m[0] ?? "";
|
|
10737
10749
|
}
|
|
10738
10750
|
}
|
|
10739
|
-
return { count, first };
|
|
10751
|
+
return { count: count2, first };
|
|
10740
10752
|
}
|
|
10741
10753
|
function countPattern(text2, re2) {
|
|
10742
10754
|
const matches = text2.match(re2) ?? [];
|
|
@@ -10974,7 +10986,7 @@ function renderOutcome(data, lines) {
|
|
|
10974
10986
|
const status = op.result?.status ?? "pending";
|
|
10975
10987
|
counts.set(status, (counts.get(status) ?? 0) + 1);
|
|
10976
10988
|
}
|
|
10977
|
-
const summary = [...counts].map(([status,
|
|
10989
|
+
const summary = [...counts].map(([status, count2]) => `${count2} ${status}`).join(", ");
|
|
10978
10990
|
lines.push(`Google Ads \u2014 published \xB7 ${summary || `${data.count} op(s)`} \xB7 ${data.mode}`);
|
|
10979
10991
|
for (const op of data.ops) {
|
|
10980
10992
|
const status = op.result?.status;
|
|
@@ -11976,11 +11988,11 @@ function renderKeywordLimits(limits) {
|
|
|
11976
11988
|
const sentences = REASON_ORDER.filter((reason) => limits.detailsByReason.has(reason)).map(
|
|
11977
11989
|
(reason) => describeLimit(reason, limits.detailsByReason.get(reason) ?? [])
|
|
11978
11990
|
);
|
|
11979
|
-
const
|
|
11991
|
+
const count2 = limits.keywords.size;
|
|
11980
11992
|
return [
|
|
11981
11993
|
{
|
|
11982
11994
|
code: "KEYWORD_SERVING_LIMITED",
|
|
11983
|
-
message: `${
|
|
11995
|
+
message: `${count2} ${count2 === 1 ? "keyword is" : "keywords are"} limited and not getting the traffic they could. ${sentences.join(" ")} Amounts are in the account currency (baker ads google currency).`
|
|
11984
11996
|
}
|
|
11985
11997
|
];
|
|
11986
11998
|
}
|
|
@@ -16678,11 +16690,11 @@ function nestAddressFlags(row) {
|
|
|
16678
16690
|
}
|
|
16679
16691
|
function readNumericFlags(args4, row) {
|
|
16680
16692
|
if (args4["conversion-count"] !== void 0 && args4["conversion-count"] !== "") {
|
|
16681
|
-
const
|
|
16682
|
-
if (!Number.isInteger(
|
|
16693
|
+
const count2 = Number(args4["conversion-count"]);
|
|
16694
|
+
if (!Number.isInteger(count2) || count2 < 1) {
|
|
16683
16695
|
failWriteValidation("--conversion-count must be a whole number of conversions, e.g. 3");
|
|
16684
16696
|
}
|
|
16685
|
-
row.conversionCount =
|
|
16697
|
+
row.conversionCount = count2;
|
|
16686
16698
|
}
|
|
16687
16699
|
if (args4.value !== void 0 && args4.value !== "") {
|
|
16688
16700
|
const numeric = Number(args4.value);
|
|
@@ -17243,12 +17255,12 @@ function keywordKillHints(status) {
|
|
|
17243
17255
|
const kills = typeof status === "string" && ["PAUSED", "REMOVED"].includes(status.toUpperCase());
|
|
17244
17256
|
return kills ? KEYWORD_KILL_HINTS : [];
|
|
17245
17257
|
}
|
|
17246
|
-
function keywordFinalUrlHints(finalUrl,
|
|
17258
|
+
function keywordFinalUrlHints(finalUrl, count2) {
|
|
17247
17259
|
if (typeof finalUrl !== "string" || finalUrl.length === 0) {
|
|
17248
17260
|
return [];
|
|
17249
17261
|
}
|
|
17250
|
-
return
|
|
17251
|
-
`--final-url applied the same landing page to all ${
|
|
17262
|
+
return count2 > 1 ? [
|
|
17263
|
+
`--final-url applied the same landing page to all ${count2} keywords \u2014 the flag has no per-keyword form. A keyword-level URL only earns its place when that keyword must land somewhere the ad doesn't, so one URL shared by every keyword belongs on the ad instead: set the ad's final URL to this page and re-stage the keywords without --final-url. Keep it only if this page is genuinely each keyword's own destination and not the ad's.`
|
|
17252
17264
|
] : [
|
|
17253
17265
|
"Keep --final-url only if this keyword's destination differs from the ad's final URL \u2014 otherwise drop it and let the keyword inherit the ad's landing page."
|
|
17254
17266
|
];
|
|
@@ -21849,7 +21861,7 @@ function renderOutcome2(data, lines) {
|
|
|
21849
21861
|
const status = op.result?.status ?? "pending";
|
|
21850
21862
|
counts.set(status, (counts.get(status) ?? 0) + 1);
|
|
21851
21863
|
}
|
|
21852
|
-
const summary = [...counts].map(([status,
|
|
21864
|
+
const summary = [...counts].map(([status, count2]) => `${count2} ${status}`).join(", ");
|
|
21853
21865
|
lines.push(`LinkedIn Ads \u2014 published \xB7 ${summary || `${data.count} op(s)`} \xB7 ${data.mode}`);
|
|
21854
21866
|
for (const op of data.ops ?? []) {
|
|
21855
21867
|
const status = op.result?.status;
|
|
@@ -26986,12 +26998,12 @@ function compact(payload) {
|
|
|
26986
26998
|
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== void 0));
|
|
26987
26999
|
}
|
|
26988
27000
|
function batchNudge2(data) {
|
|
26989
|
-
const
|
|
26990
|
-
if (!
|
|
27001
|
+
const count2 = data?.sameKindStaged;
|
|
27002
|
+
if (!count2) {
|
|
26991
27003
|
return [];
|
|
26992
27004
|
}
|
|
26993
27005
|
return [
|
|
26994
|
-
`${
|
|
27006
|
+
`${count2} other change${count2 === 1 ? "" : "s"} of this kind and payload ${count2 === 1 ? "is" : "are"} already staged \u2014 pass every id to one call (\`--ids a,b,c\`) instead of one call each.`
|
|
26995
27007
|
];
|
|
26996
27008
|
}
|
|
26997
27009
|
async function stageXOp(raw, hints2) {
|
|
@@ -35848,14 +35860,14 @@ function canvasDims(ar) {
|
|
|
35848
35860
|
function fillPanel(label, w, h, out) {
|
|
35849
35861
|
return `[${label}]scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h},setsar=1,fps=30[${out}]`;
|
|
35850
35862
|
}
|
|
35851
|
-
function splitStackArgs(
|
|
35863
|
+
function splitStackArgs(count2, axis, dims) {
|
|
35852
35864
|
const span = axis === "horizontal" ? dims.w : dims.h;
|
|
35853
|
-
const base = Math.floor(span /
|
|
35854
|
-
const sizeAt = (i) => i ===
|
|
35865
|
+
const base = Math.floor(span / count2 / 2) * 2;
|
|
35866
|
+
const sizeAt = (i) => i === count2 - 1 ? span - base * (count2 - 1) : base;
|
|
35855
35867
|
const inputs = [];
|
|
35856
35868
|
const filt = [];
|
|
35857
35869
|
let labels = "";
|
|
35858
|
-
for (let i = 0; i <
|
|
35870
|
+
for (let i = 0; i < count2; i++) {
|
|
35859
35871
|
inputs.push("-i", `{{in.c${i}}}`);
|
|
35860
35872
|
const pw = axis === "horizontal" ? sizeAt(i) : dims.w;
|
|
35861
35873
|
const ph = axis === "vertical" ? sizeAt(i) : dims.h;
|
|
@@ -35863,7 +35875,7 @@ function splitStackArgs(count, axis, dims) {
|
|
|
35863
35875
|
labels += `[p${i}]`;
|
|
35864
35876
|
}
|
|
35865
35877
|
const stack = axis === "vertical" ? "vstack" : "hstack";
|
|
35866
|
-
filt.push(`${labels}${stack}=inputs=${
|
|
35878
|
+
filt.push(`${labels}${stack}=inputs=${count2}[v]`);
|
|
35867
35879
|
return [...inputs, "-filter_complex", filt.join(";"), "-map", "[v]", "{{out.video}}"];
|
|
35868
35880
|
}
|
|
35869
35881
|
function overlayXY(position, marginPx) {
|
|
@@ -42537,13 +42549,13 @@ async function markDirectoryIsReal(dir) {
|
|
|
42537
42549
|
var INK_ALPHA = 128;
|
|
42538
42550
|
function inkLuma(rgba) {
|
|
42539
42551
|
let sum = 0;
|
|
42540
|
-
let
|
|
42552
|
+
let count2 = 0;
|
|
42541
42553
|
for (let i = 0; i + 3 < rgba.length; i += 4) {
|
|
42542
42554
|
if (rgba[i + 3] < INK_ALPHA) continue;
|
|
42543
42555
|
sum += 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
|
|
42544
|
-
|
|
42556
|
+
count2 += 1;
|
|
42545
42557
|
}
|
|
42546
|
-
return
|
|
42558
|
+
return count2 === 0 ? null : sum / count2;
|
|
42547
42559
|
}
|
|
42548
42560
|
var LIGHT_INK_LUMA = 170;
|
|
42549
42561
|
async function markInkIsLight(markPath) {
|
|
@@ -45008,185 +45020,6 @@ async function foldExperiment(root, target) {
|
|
|
45008
45020
|
};
|
|
45009
45021
|
}
|
|
45010
45022
|
|
|
45011
|
-
// src/commands/experiment/hints.ts
|
|
45012
|
-
var LIVE_HINT = {
|
|
45013
|
-
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)}`,
|
|
45014
|
-
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.`,
|
|
45015
|
-
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.`,
|
|
45016
|
-
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.`,
|
|
45017
|
-
// Unreachable on a live test — only ending one produces it. The Record is
|
|
45018
|
-
// exhaustive by type so a verdict added upstream fails to compile here rather
|
|
45019
|
-
// than printing nothing.
|
|
45020
|
-
inconclusive: (row) => `${row.experimentId} was ended before it concluded and found nothing.`,
|
|
45021
|
-
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.`
|
|
45022
|
-
};
|
|
45023
|
-
function describeWait(row) {
|
|
45024
|
-
if (row.willNotConclude) {
|
|
45025
|
-
return "This page's traffic will not settle it at any point, so end it with `--keep original` and test a bigger change.";
|
|
45026
|
-
}
|
|
45027
|
-
if (row.earliestDecisionAt === null) {
|
|
45028
|
-
return "This page has produced no visitors yet, so there is nothing to project a finish date from.";
|
|
45029
|
-
}
|
|
45030
|
-
return `Earliest it could say anything: ${new Date(row.earliestDecisionAt).toISOString().slice(0, 10)}.`;
|
|
45031
|
-
}
|
|
45032
|
-
function pausedNote(row) {
|
|
45033
|
-
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. ` : "";
|
|
45034
|
-
}
|
|
45035
|
-
function unlearnedHint(experiments) {
|
|
45036
|
-
const unlearned = experiments.filter(
|
|
45037
|
-
(row) => row.status === "finished" && row.learning === null && row.verdict !== "inconclusive"
|
|
45038
|
-
);
|
|
45039
|
-
if (unlearned.length === 0) return null;
|
|
45040
|
-
const ids = unlearned.map((row) => row.experimentId).join(", ");
|
|
45041
|
-
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.`;
|
|
45042
|
-
}
|
|
45043
|
-
function buildStatusHints(experiments) {
|
|
45044
|
-
const hints2 = experiments.filter((row) => row.status !== "finished").map((row) => pausedNote(row) + LIVE_HINT[row.verdict](row));
|
|
45045
|
-
const unlearned = unlearnedHint(experiments);
|
|
45046
|
-
if (unlearned) hints2.push(unlearned);
|
|
45047
|
-
if (experiments.length === 0) {
|
|
45048
|
-
hints2.push(
|
|
45049
|
-
"No tests yet. `baker experiment plan --landing <slug> --variant <slug>` says whether a page has enough traffic for one before you build the alternative."
|
|
45050
|
-
);
|
|
45051
|
-
}
|
|
45052
|
-
return hints2;
|
|
45053
|
-
}
|
|
45054
|
-
function asLift(value) {
|
|
45055
|
-
return `+${Math.round(value * 100)}%`;
|
|
45056
|
-
}
|
|
45057
|
-
function horizonsHint(plan) {
|
|
45058
|
-
const settled = plan.horizons.filter((horizon) => horizon.detectableLift !== null);
|
|
45059
|
-
if (settled.length === 0) {
|
|
45060
|
-
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.";
|
|
45061
|
-
}
|
|
45062
|
-
const parts = settled.map((horizon) => `${horizon.days} days \u2192 ${asLift(horizon.detectableLift ?? 0)}`);
|
|
45063
|
-
return `The smallest change each window could settle on this page's traffic: ${parts.join(", ")}. Design the alternative to be at least that different.`;
|
|
45064
|
-
}
|
|
45065
|
-
function conversionsHint(plan) {
|
|
45066
|
-
if (plan.conversions.length <= 1) return null;
|
|
45067
|
-
const others = plan.conversions.filter((entry) => entry.eventKey !== plan.goal);
|
|
45068
|
-
return `This page's visitors also went on to: ${others.map((entry) => `\`${entry.eventKey}\`${entry.name ? ` (\u201C${entry.name}\u201D)` : ""} \u2014 ${entry.convertingVisitors}`).join(
|
|
45069
|
-
", "
|
|
45070
|
-
)}. Any of them can be the goal (--goal <key>); the named ones are reported beside it on every result, and decide nothing.`;
|
|
45071
|
-
}
|
|
45072
|
-
function pastTestLine(test) {
|
|
45073
|
-
const outcome = {
|
|
45074
|
-
supported: "the idea held",
|
|
45075
|
-
refuted_opposite: "the idea was WRONG \u2014 the change made it worse",
|
|
45076
|
-
refuted_no_effect: "no difference \u2014 the change did not matter",
|
|
45077
|
-
unanswered: "never answered"
|
|
45078
|
-
};
|
|
45079
|
-
const kept = test.survivor === "variant" ? "the new version was kept" : "the original was kept";
|
|
45080
|
-
const learning = test.learning ? ` Learning: ${test.learning}` : "";
|
|
45081
|
-
return `\u201C${test.hypothesis.change}\u201D (${test.experimentId}, ${test.ranForDays} days): ${outcome[test.hypothesisOutcome]}; ${kept}${test.againstVerdict ? " against the numbers" : ""}.${learning}`;
|
|
45082
|
-
}
|
|
45083
|
-
function pastTestsHint(plan) {
|
|
45084
|
-
if (plan.pastTests.length === 0) return null;
|
|
45085
|
-
const lines = plan.pastTests.slice(0, 5).map(pastTestLine);
|
|
45086
|
-
const more = plan.pastTests.length > 5 ? ` \u2026and ${plan.pastTests.length - 5} more \u2014 \`baker experiment history --landing <slug>\` lists them all.` : "";
|
|
45087
|
-
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}`;
|
|
45088
|
-
}
|
|
45089
|
-
function buildPlanHints(plan) {
|
|
45090
|
-
const horizons = horizonsHint(plan);
|
|
45091
|
-
const past = pastTestsHint(plan);
|
|
45092
|
-
if (!plan.canRun) {
|
|
45093
|
-
return [
|
|
45094
|
-
plan.reason ?? "This test cannot run on this page.",
|
|
45095
|
-
...horizons && plan.visitors > 0 ? [horizons] : [],
|
|
45096
|
-
...past ? [past] : [],
|
|
45097
|
-
"A test that cannot conclude is worse than no test \u2014 it still produces a number, and somebody acts on it."
|
|
45098
|
-
];
|
|
45099
|
-
}
|
|
45100
|
-
const hints2 = [
|
|
45101
|
-
...past ? [past] : [],
|
|
45102
|
-
// First, because it changes how every number below should be read: a plan
|
|
45103
|
-
// sized on a stand-in is a guide, and the agent must say so to the client
|
|
45104
|
-
// rather than quote "about 70 days" as though it were measured.
|
|
45105
|
-
...plan.warning ? [plan.warning] : [],
|
|
45106
|
-
`This test is read on \u201C${plan.goal}\u201D. That is fixed when it starts and cannot be changed later.`,
|
|
45107
|
-
// Fires at the exact moment the hypothesis gets invented, whether or not
|
|
45108
|
-
// the agent read the family doc. `plan` says a question CAN be settled here
|
|
45109
|
-
// and says nothing about which question is worth asking — and a well-run
|
|
45110
|
-
// test of a bad idea spends the page's traffic and returns `no_difference`.
|
|
45111
|
-
"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.",
|
|
45112
|
-
// The base always works, so a missing integration is never a reason to
|
|
45113
|
-
// stop. Naming what it would have shown is worth more to the client than
|
|
45114
|
-
// either a silent gap or a run that waited.
|
|
45115
|
-
"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."
|
|
45116
|
-
];
|
|
45117
|
-
const conversions = conversionsHint(plan);
|
|
45118
|
-
if (conversions) hints2.push(conversions);
|
|
45119
|
-
if (plan.estimatedDays !== null && plan.estimatedDays > 60) {
|
|
45120
|
-
hints2.push(
|
|
45121
|
-
`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.`
|
|
45122
|
-
);
|
|
45123
|
-
}
|
|
45124
|
-
if (horizons) hints2.push(horizons);
|
|
45125
|
-
return hints2;
|
|
45126
|
-
}
|
|
45127
|
-
function buildHistoryHints(tests, landing) {
|
|
45128
|
-
if (tests.length === 0) {
|
|
45129
|
-
return [
|
|
45130
|
-
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."
|
|
45131
|
-
];
|
|
45132
|
-
}
|
|
45133
|
-
const hints2 = [];
|
|
45134
|
-
const supported = tests.filter((test) => test.hypothesisOutcome === "supported");
|
|
45135
|
-
const refuted = tests.filter((test) => test.hypothesisOutcome === "refuted_opposite");
|
|
45136
|
-
const noEffect = tests.filter((test) => test.hypothesisOutcome === "refuted_no_effect");
|
|
45137
|
-
if (supported.length > 0) {
|
|
45138
|
-
hints2.push(
|
|
45139
|
-
`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.`
|
|
45140
|
-
);
|
|
45141
|
-
}
|
|
45142
|
-
if (refuted.length > 0) {
|
|
45143
|
-
hints2.push(
|
|
45144
|
-
`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.`
|
|
45145
|
-
);
|
|
45146
|
-
}
|
|
45147
|
-
if (noEffect.length > 0) {
|
|
45148
|
-
hints2.push(
|
|
45149
|
-
`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.`
|
|
45150
|
-
);
|
|
45151
|
-
}
|
|
45152
|
-
const unlearned = tests.filter((test) => test.learning === null && test.verdict !== "inconclusive");
|
|
45153
|
-
if (unlearned.length > 0) {
|
|
45154
|
-
hints2.push(
|
|
45155
|
-
`${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"\`.`
|
|
45156
|
-
);
|
|
45157
|
-
}
|
|
45158
|
-
return hints2;
|
|
45159
|
-
}
|
|
45160
|
-
function buildStartHints(staged) {
|
|
45161
|
-
const hints2 = [];
|
|
45162
|
-
for (const entry of staged.drift) {
|
|
45163
|
-
hints2.push(driftHint(staged, entry));
|
|
45164
|
-
}
|
|
45165
|
-
if (staged.composition && staged.composition.forked.length === 0) {
|
|
45166
|
-
hints2.push(
|
|
45167
|
-
`\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.`
|
|
45168
|
-
);
|
|
45169
|
-
}
|
|
45170
|
-
if (staged.newForms.length > 0 && staged.variantGoal === void 0) {
|
|
45171
|
-
const forms = staged.newForms.map((flow) => `\u201C${flow}\u201D`).join(", ");
|
|
45172
|
-
hints2.push(
|
|
45173
|
-
`\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).`
|
|
45174
|
-
);
|
|
45175
|
-
}
|
|
45176
|
-
return hints2;
|
|
45177
|
-
}
|
|
45178
|
-
function driftHint(staged, entry) {
|
|
45179
|
-
const suffix = ` Check both versions before reading that number, and give the new version's step its own name if it is not the same action (\`baker analytics conversions --candidates\`).`;
|
|
45180
|
-
if (entry.reason === "trigger_differs") {
|
|
45181
|
-
return `\u201C${staged.landing}\u201D and \u201C${staged.variant}\u201D both count \`${entry.name}\`, but one fires it when the element is **scrolled into view** and the other when the visitor **does something**. Those are two different questions with one answer, and the test will report a difference that is the trigger rather than the page.${suffix}`;
|
|
45182
|
-
}
|
|
45183
|
-
if (entry.reason === "variant_only_missing" || entry.reason === "control_only_missing") {
|
|
45184
|
-
const missing = entry.reason === "variant_only_missing" ? staged.variant : staged.landing;
|
|
45185
|
-
return `Only one version fires \`${entry.name}\` \u2014 \u201C${missing}\u201D does not declare it at all, so it can only ever read zero for that conversion and the comparison will look like a collapse.${suffix}`;
|
|
45186
|
-
}
|
|
45187
|
-
return `Both versions count \`${entry.name}\`, and the section the test changed is what fires it \u2014 the markup around it is not the same on the two pages. If the new version fires it on a different action (a block that is visible from the start rather than one the visitor opens), that conversion is comparing two different things.${suffix}`;
|
|
45188
|
-
}
|
|
45189
|
-
|
|
45190
45023
|
// ../api/src/experiments/composition.ts
|
|
45191
45024
|
import { z as z41 } from "zod";
|
|
45192
45025
|
var experimentCompositionSchema = z41.object({
|
|
@@ -45251,6 +45084,25 @@ var experimentHypothesisOutcomeSchema = z43.enum([
|
|
|
45251
45084
|
"unanswered"
|
|
45252
45085
|
]);
|
|
45253
45086
|
|
|
45087
|
+
// ../api/src/experiments/wait.ts
|
|
45088
|
+
var DAY_MS5 = 24 * 60 * 60 * 1e3;
|
|
45089
|
+
function count(value, unit) {
|
|
45090
|
+
return `about ${value} ${unit}${value === 1 ? "" : "s"}`;
|
|
45091
|
+
}
|
|
45092
|
+
function describeWait(earliestDecisionAt, now) {
|
|
45093
|
+
const days = (earliestDecisionAt - now) / DAY_MS5;
|
|
45094
|
+
if (days <= 0) return "any moment now";
|
|
45095
|
+
if (days < 1) return "within a day";
|
|
45096
|
+
if (days < 14) return count(Math.round(days), "day");
|
|
45097
|
+
if (days < 60) return count(Math.round(days / 7), "week");
|
|
45098
|
+
return count(Math.round(days / 30), "month");
|
|
45099
|
+
}
|
|
45100
|
+
function roughCount(count2) {
|
|
45101
|
+
if (count2 < 10) return `about ${Math.max(1, Math.round(count2)).toLocaleString("en-US")}`;
|
|
45102
|
+
const magnitude = 10 ** Math.max(1, Math.floor(Math.log10(count2)) - 1);
|
|
45103
|
+
return `about ${(Math.round(count2 / magnitude) * magnitude).toLocaleString("en-US")}`;
|
|
45104
|
+
}
|
|
45105
|
+
|
|
45254
45106
|
// ../api/src/experiments/wire.ts
|
|
45255
45107
|
import { z as z44 } from "zod";
|
|
45256
45108
|
var slugSchema = z44.string().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use a page slug, like `oferta`");
|
|
@@ -45470,7 +45322,17 @@ var experimentVerdictSchema = z44.enum([
|
|
|
45470
45322
|
"keep_running",
|
|
45471
45323
|
"winner",
|
|
45472
45324
|
"no_difference",
|
|
45325
|
+
/**
|
|
45326
|
+
* Even the best case is under the lift the test was built to find, so there
|
|
45327
|
+
* is nothing left for it to discover: keep the original. The "is this a
|
|
45328
|
+
* failure" answer — not proven worse, but proven not worth running.
|
|
45329
|
+
*/
|
|
45330
|
+
"cannot_win",
|
|
45473
45331
|
"invalid",
|
|
45332
|
+
/**
|
|
45333
|
+
* Only on tests read before the stopping rule was one interval: a version
|
|
45334
|
+
* proven worse is now `winner` with `winner: "control"`, whenever it is seen.
|
|
45335
|
+
*/
|
|
45474
45336
|
"stopped_early_harmful",
|
|
45475
45337
|
/**
|
|
45476
45338
|
* Ended before the numbers could say anything. Never produced by the
|
|
@@ -45615,6 +45477,14 @@ var experimentStatusRowSchema = z44.object({
|
|
|
45615
45477
|
precision: z44.number(),
|
|
45616
45478
|
/** When the test could first say something. `null` with no traffic to project from. */
|
|
45617
45479
|
earliestDecisionAt: z44.number().nullable(),
|
|
45480
|
+
/**
|
|
45481
|
+
* What the test is still missing before its verdict changes, if the gap it
|
|
45482
|
+
* is seeing holds: visitors across both arms, and the conversions they bring.
|
|
45483
|
+
* A test is gated on data, never on days — `earliestDecisionAt` is only this
|
|
45484
|
+
* divided by the page's pace. Null once it has spoken, before anyone has
|
|
45485
|
+
* arrived, or when no amount of traffic would settle it.
|
|
45486
|
+
*/
|
|
45487
|
+
stillNeeded: z44.object({ visitors: z44.number(), conversions: z44.number() }).nullable().default(null),
|
|
45618
45488
|
/**
|
|
45619
45489
|
* True when no amount of this page's traffic would settle the comparison.
|
|
45620
45490
|
* A different thing from a page that is merely slow, and the reason to end
|
|
@@ -45699,6 +45569,190 @@ var experimentFinishResponseSchema = z44.object({
|
|
|
45699
45569
|
summary: z44.string()
|
|
45700
45570
|
});
|
|
45701
45571
|
|
|
45572
|
+
// src/commands/experiment/hints.ts
|
|
45573
|
+
var LIVE_HINT = {
|
|
45574
|
+
keep_running: (row, now) => `${row.experimentId} cannot yet tell the two pages apart \u2014 do not end it hoping for a result. ${describeWait2(row, now)} To the user, say only this in plain words: "${row.summary}" plus how much more data it needs and roughly when. Never quote confidence, chance-better or intervals to them \u2014 those numbers confuse marketers and decide nothing.`,
|
|
45575
|
+
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}: the new version is proven worse and is STILL being shown to half the visitors. Run \`baker experiment finish --id ${row.experimentId} --keep original\` now.`,
|
|
45576
|
+
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.`,
|
|
45577
|
+
cannot_win: (row) => `${row.experimentId}: the new version cannot win \u2014 even its best case is under the lift this test was looking for, so there is nothing left to find. Run \`baker experiment finish --id ${row.experimentId} --keep original --reason numbers_clear\` and write down what it taught.`,
|
|
45578
|
+
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.`,
|
|
45579
|
+
// Unreachable on a live test — only ending one produces it. The Record is
|
|
45580
|
+
// exhaustive by type so a verdict added upstream fails to compile here rather
|
|
45581
|
+
// than printing nothing.
|
|
45582
|
+
inconclusive: (row) => `${row.experimentId} was ended before it concluded and found nothing.`,
|
|
45583
|
+
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.`
|
|
45584
|
+
};
|
|
45585
|
+
function describeWait2(row, now) {
|
|
45586
|
+
if (row.willNotConclude) {
|
|
45587
|
+
return "This page's traffic will not settle it at any point, so end it with `--keep original` and test a bigger change.";
|
|
45588
|
+
}
|
|
45589
|
+
if (row.earliestDecisionAt === null) {
|
|
45590
|
+
return "This page has produced no visitors yet, so there is nothing to project a finish date from.";
|
|
45591
|
+
}
|
|
45592
|
+
if (row.stillNeeded === null) {
|
|
45593
|
+
return `It already has the data it needs; the verdict waits only until it has seen a full day of visitors \u2014 ${describeWait(row.earliestDecisionAt, now)}.`;
|
|
45594
|
+
}
|
|
45595
|
+
const needed = `It needs ${roughCount(row.stillNeeded.visitors)} more visitors (${roughCount(row.stillNeeded.conversions)} more conversions) for an answer if the gap holds \u2014 `;
|
|
45596
|
+
return `${needed}${describeWait(row.earliestDecisionAt, now)} at today's traffic (${new Date(row.earliestDecisionAt).toISOString().slice(0, 10)}). A test is gated on this data, never on days.`;
|
|
45597
|
+
}
|
|
45598
|
+
function pausedNote(row) {
|
|
45599
|
+
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. ` : "";
|
|
45600
|
+
}
|
|
45601
|
+
function unlearnedHint(experiments) {
|
|
45602
|
+
const unlearned = experiments.filter(
|
|
45603
|
+
(row) => row.status === "finished" && row.learning === null && row.verdict !== "inconclusive"
|
|
45604
|
+
);
|
|
45605
|
+
if (unlearned.length === 0) return null;
|
|
45606
|
+
const ids = unlearned.map((row) => row.experimentId).join(", ");
|
|
45607
|
+
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.`;
|
|
45608
|
+
}
|
|
45609
|
+
function buildStatusHints(experiments, now = Date.now()) {
|
|
45610
|
+
const hints2 = experiments.filter((row) => row.status !== "finished").map((row) => pausedNote(row) + LIVE_HINT[row.verdict](row, now));
|
|
45611
|
+
const unlearned = unlearnedHint(experiments);
|
|
45612
|
+
if (unlearned) hints2.push(unlearned);
|
|
45613
|
+
if (experiments.length === 0) {
|
|
45614
|
+
hints2.push(
|
|
45615
|
+
"No tests yet. `baker experiment plan --landing <slug> --variant <slug>` says whether a page has enough traffic for one before you build the alternative."
|
|
45616
|
+
);
|
|
45617
|
+
}
|
|
45618
|
+
return hints2;
|
|
45619
|
+
}
|
|
45620
|
+
function asLift(value) {
|
|
45621
|
+
return `+${Math.round(value * 100)}%`;
|
|
45622
|
+
}
|
|
45623
|
+
function horizonsHint(plan) {
|
|
45624
|
+
const settled = plan.horizons.filter((horizon) => horizon.detectableLift !== null);
|
|
45625
|
+
if (settled.length === 0) {
|
|
45626
|
+
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.";
|
|
45627
|
+
}
|
|
45628
|
+
const parts = settled.map((horizon) => `${horizon.days} days \u2192 ${asLift(horizon.detectableLift ?? 0)}`);
|
|
45629
|
+
return `The smallest change each window could settle on this page's traffic: ${parts.join(", ")}. Design the alternative to be at least that different.`;
|
|
45630
|
+
}
|
|
45631
|
+
function conversionsHint(plan) {
|
|
45632
|
+
if (plan.conversions.length <= 1) return null;
|
|
45633
|
+
const others = plan.conversions.filter((entry) => entry.eventKey !== plan.goal);
|
|
45634
|
+
return `This page's visitors also went on to: ${others.map((entry) => `\`${entry.eventKey}\`${entry.name ? ` (\u201C${entry.name}\u201D)` : ""} \u2014 ${entry.convertingVisitors}`).join(
|
|
45635
|
+
", "
|
|
45636
|
+
)}. Any of them can be the goal (--goal <key>); the named ones are reported beside it on every result, and decide nothing.`;
|
|
45637
|
+
}
|
|
45638
|
+
function pastTestLine(test) {
|
|
45639
|
+
const outcome = {
|
|
45640
|
+
supported: "the idea held",
|
|
45641
|
+
refuted_opposite: "the idea was WRONG \u2014 the change made it worse",
|
|
45642
|
+
refuted_no_effect: "no difference \u2014 the change did not matter",
|
|
45643
|
+
unanswered: "never answered"
|
|
45644
|
+
};
|
|
45645
|
+
const kept = test.survivor === "variant" ? "the new version was kept" : "the original was kept";
|
|
45646
|
+
const learning = test.learning ? ` Learning: ${test.learning}` : "";
|
|
45647
|
+
return `\u201C${test.hypothesis.change}\u201D (${test.experimentId}, ${test.ranForDays} days): ${outcome[test.hypothesisOutcome]}; ${kept}${test.againstVerdict ? " against the numbers" : ""}.${learning}`;
|
|
45648
|
+
}
|
|
45649
|
+
function pastTestsHint(plan) {
|
|
45650
|
+
if (plan.pastTests.length === 0) return null;
|
|
45651
|
+
const lines = plan.pastTests.slice(0, 5).map(pastTestLine);
|
|
45652
|
+
const more = plan.pastTests.length > 5 ? ` \u2026and ${plan.pastTests.length - 5} more \u2014 \`baker experiment history --landing <slug>\` lists them all.` : "";
|
|
45653
|
+
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}`;
|
|
45654
|
+
}
|
|
45655
|
+
function buildPlanHints(plan) {
|
|
45656
|
+
const horizons = horizonsHint(plan);
|
|
45657
|
+
const past = pastTestsHint(plan);
|
|
45658
|
+
if (!plan.canRun) {
|
|
45659
|
+
return [
|
|
45660
|
+
plan.reason ?? "This test cannot run on this page.",
|
|
45661
|
+
...horizons && plan.visitors > 0 ? [horizons] : [],
|
|
45662
|
+
...past ? [past] : [],
|
|
45663
|
+
"A test that cannot conclude is worse than no test \u2014 it still produces a number, and somebody acts on it."
|
|
45664
|
+
];
|
|
45665
|
+
}
|
|
45666
|
+
const hints2 = [
|
|
45667
|
+
...past ? [past] : [],
|
|
45668
|
+
// First, because it changes how every number below should be read: a plan
|
|
45669
|
+
// sized on a stand-in is a guide, and the agent must say so to the client
|
|
45670
|
+
// rather than quote "about 70 days" as though it were measured.
|
|
45671
|
+
...plan.warning ? [plan.warning] : [],
|
|
45672
|
+
`This test is read on \u201C${plan.goal}\u201D. That is fixed when it starts and cannot be changed later.`,
|
|
45673
|
+
// Fires at the exact moment the hypothesis gets invented, whether or not
|
|
45674
|
+
// the agent read the family doc. `plan` says a question CAN be settled here
|
|
45675
|
+
// and says nothing about which question is worth asking — and a well-run
|
|
45676
|
+
// test of a bad idea spends the page's traffic and returns `no_difference`.
|
|
45677
|
+
"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.",
|
|
45678
|
+
// The base always works, so a missing integration is never a reason to
|
|
45679
|
+
// stop. Naming what it would have shown is worth more to the client than
|
|
45680
|
+
// either a silent gap or a run that waited.
|
|
45681
|
+
"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."
|
|
45682
|
+
];
|
|
45683
|
+
const conversions = conversionsHint(plan);
|
|
45684
|
+
if (conversions) hints2.push(conversions);
|
|
45685
|
+
if (plan.estimatedDays !== null && plan.estimatedDays > 60) {
|
|
45686
|
+
hints2.push(
|
|
45687
|
+
`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.`
|
|
45688
|
+
);
|
|
45689
|
+
}
|
|
45690
|
+
if (horizons) hints2.push(horizons);
|
|
45691
|
+
return hints2;
|
|
45692
|
+
}
|
|
45693
|
+
function buildHistoryHints(tests, landing) {
|
|
45694
|
+
if (tests.length === 0) {
|
|
45695
|
+
return [
|
|
45696
|
+
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."
|
|
45697
|
+
];
|
|
45698
|
+
}
|
|
45699
|
+
const hints2 = [];
|
|
45700
|
+
const supported = tests.filter((test) => test.hypothesisOutcome === "supported");
|
|
45701
|
+
const refuted = tests.filter((test) => test.hypothesisOutcome === "refuted_opposite");
|
|
45702
|
+
const noEffect = tests.filter((test) => test.hypothesisOutcome === "refuted_no_effect");
|
|
45703
|
+
if (supported.length > 0) {
|
|
45704
|
+
hints2.push(
|
|
45705
|
+
`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.`
|
|
45706
|
+
);
|
|
45707
|
+
}
|
|
45708
|
+
if (refuted.length > 0) {
|
|
45709
|
+
hints2.push(
|
|
45710
|
+
`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.`
|
|
45711
|
+
);
|
|
45712
|
+
}
|
|
45713
|
+
if (noEffect.length > 0) {
|
|
45714
|
+
hints2.push(
|
|
45715
|
+
`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.`
|
|
45716
|
+
);
|
|
45717
|
+
}
|
|
45718
|
+
const unlearned = tests.filter((test) => test.learning === null && test.verdict !== "inconclusive");
|
|
45719
|
+
if (unlearned.length > 0) {
|
|
45720
|
+
hints2.push(
|
|
45721
|
+
`${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"\`.`
|
|
45722
|
+
);
|
|
45723
|
+
}
|
|
45724
|
+
return hints2;
|
|
45725
|
+
}
|
|
45726
|
+
function buildStartHints(staged) {
|
|
45727
|
+
const hints2 = [];
|
|
45728
|
+
for (const entry of staged.drift) {
|
|
45729
|
+
hints2.push(driftHint(staged, entry));
|
|
45730
|
+
}
|
|
45731
|
+
if (staged.composition && staged.composition.forked.length === 0) {
|
|
45732
|
+
hints2.push(
|
|
45733
|
+
`\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.`
|
|
45734
|
+
);
|
|
45735
|
+
}
|
|
45736
|
+
if (staged.newForms.length > 0 && staged.variantGoal === void 0) {
|
|
45737
|
+
const forms = staged.newForms.map((flow) => `\u201C${flow}\u201D`).join(", ");
|
|
45738
|
+
hints2.push(
|
|
45739
|
+
`\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).`
|
|
45740
|
+
);
|
|
45741
|
+
}
|
|
45742
|
+
return hints2;
|
|
45743
|
+
}
|
|
45744
|
+
function driftHint(staged, entry) {
|
|
45745
|
+
const suffix = ` Check both versions before reading that number, and give the new version's step its own name if it is not the same action (\`baker analytics conversions --candidates\`).`;
|
|
45746
|
+
if (entry.reason === "trigger_differs") {
|
|
45747
|
+
return `\u201C${staged.landing}\u201D and \u201C${staged.variant}\u201D both count \`${entry.name}\`, but one fires it when the element is **scrolled into view** and the other when the visitor **does something**. Those are two different questions with one answer, and the test will report a difference that is the trigger rather than the page.${suffix}`;
|
|
45748
|
+
}
|
|
45749
|
+
if (entry.reason === "variant_only_missing" || entry.reason === "control_only_missing") {
|
|
45750
|
+
const missing = entry.reason === "variant_only_missing" ? staged.variant : staged.landing;
|
|
45751
|
+
return `Only one version fires \`${entry.name}\` \u2014 \u201C${missing}\u201D does not declare it at all, so it can only ever read zero for that conversion and the comparison will look like a collapse.${suffix}`;
|
|
45752
|
+
}
|
|
45753
|
+
return `Both versions count \`${entry.name}\`, and the section the test changed is what fires it \u2014 the markup around it is not the same on the two pages. If the new version fires it on a different action (a block that is visible from the start rather than one the visitor opens), that conversion is comparing two different things.${suffix}`;
|
|
45754
|
+
}
|
|
45755
|
+
|
|
45702
45756
|
// src/commands/experiment/hypothesis.ts
|
|
45703
45757
|
var SOURCES = experimentEvidenceSourceSchema.options.join(", ");
|
|
45704
45758
|
function parseOne(raw) {
|
|
@@ -46056,7 +46110,7 @@ registerSchema({
|
|
|
46056
46110
|
var statusCommand6 = defineCommand123({
|
|
46057
46111
|
meta: {
|
|
46058
46112
|
name: "status",
|
|
46059
|
-
description: "The verdict on each test. Read `verdict` and nothing else to decide: keep_running | winner | no_difference |
|
|
46113
|
+
description: "The verdict on each test. Read `verdict` and nothing else to decide: keep_running | winner | no_difference | cannot_win | invalid. `cannot_win` means even the best case is under the lift the test was looking for \u2014 end it and keep the original. `stillNeeded` is the data (visitors, conversions) the test still needs; a test is gated on data, never on days. `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."
|
|
46060
46114
|
},
|
|
46061
46115
|
args: {
|
|
46062
46116
|
id: { type: "string", description: "One test, by its id", required: false },
|
|
@@ -52809,7 +52863,7 @@ async function runMediaGroupLookup(input) {
|
|
|
52809
52863
|
const data = await apiGet(`${input.path}?${query.toString()}`);
|
|
52810
52864
|
const images = data.images.map((image) => compactImage(image));
|
|
52811
52865
|
const videos = (data.videos ?? []).map((video) => compactVideo(video));
|
|
52812
|
-
const
|
|
52866
|
+
const count2 = images.length + videos.length;
|
|
52813
52867
|
writeJson({
|
|
52814
52868
|
ok: true,
|
|
52815
52869
|
data: {
|
|
@@ -52819,11 +52873,11 @@ async function runMediaGroupLookup(input) {
|
|
|
52819
52873
|
// place the campaign is named.
|
|
52820
52874
|
groupTitle: data.groupTitle ?? null,
|
|
52821
52875
|
groupUrl: data.groupUrl ?? null,
|
|
52822
|
-
count,
|
|
52876
|
+
count: count2,
|
|
52823
52877
|
images,
|
|
52824
52878
|
videos
|
|
52825
52879
|
},
|
|
52826
|
-
hints:
|
|
52880
|
+
hints: count2 > 1 ? ["These arrived together and are ordered. Read them in sequence before using any one of them alone."] : void 0
|
|
52827
52881
|
});
|
|
52828
52882
|
} catch (err) {
|
|
52829
52883
|
if (err instanceof ApiError) {
|
|
@@ -53523,9 +53577,9 @@ function getDominantEdgeColor(data, width, height) {
|
|
|
53523
53577
|
let maxCount = 0;
|
|
53524
53578
|
let dominantColor = { r: 0, g: 0, b: 0 };
|
|
53525
53579
|
for (const key in colorCount) {
|
|
53526
|
-
const
|
|
53527
|
-
if (
|
|
53528
|
-
maxCount =
|
|
53580
|
+
const count2 = colorCount[key];
|
|
53581
|
+
if (count2 > maxCount) {
|
|
53582
|
+
maxCount = count2;
|
|
53529
53583
|
const parts = key.split(",").map(Number);
|
|
53530
53584
|
dominantColor = { r: parts[0], g: parts[1], b: parts[2] };
|
|
53531
53585
|
}
|
|
@@ -55969,10 +56023,10 @@ var ANALYZERS = [
|
|
|
55969
56023
|
scope: "page",
|
|
55970
56024
|
run: (text2, file) => {
|
|
55971
56025
|
const body = stripHtmlToText(text2).replace(/—|�*8212;|�*2014;/gi, "\u2014");
|
|
55972
|
-
const
|
|
55973
|
-
if (
|
|
55974
|
-
if (body.length >
|
|
55975
|
-
return [{ id: "em-dash-overuse", snippet: `${
|
|
56026
|
+
const count2 = (body.match(/[—]|(?<!-)--(?!-)(?=\S)/g) ?? []).length;
|
|
56027
|
+
if (count2 < EM_DASH_FLOOR) return [];
|
|
56028
|
+
if (body.length > count2 * EM_DASH_CHARS_PER_DASH) return [];
|
|
56029
|
+
return [{ id: "em-dash-overuse", snippet: `${count2} em-dashes in body copy`, file }];
|
|
55976
56030
|
}
|
|
55977
56031
|
},
|
|
55978
56032
|
// Agent washing: an autonomy claim the page never scaffolds with a signal about
|
|
@@ -55999,25 +56053,25 @@ var ANALYZERS = [
|
|
|
55999
56053
|
run: (text2, file) => {
|
|
56000
56054
|
const body = stripHtmlToText(text2);
|
|
56001
56055
|
const lower = body.toLowerCase();
|
|
56002
|
-
let
|
|
56056
|
+
let count2 = 0;
|
|
56003
56057
|
let firstSample = "";
|
|
56004
56058
|
for (const phrase of BUZZWORDS) {
|
|
56005
56059
|
let from = 0;
|
|
56006
56060
|
for (; ; ) {
|
|
56007
56061
|
const idx = lower.indexOf(phrase, from);
|
|
56008
56062
|
if (idx === -1) break;
|
|
56009
|
-
|
|
56063
|
+
count2++;
|
|
56010
56064
|
if (!firstSample) {
|
|
56011
56065
|
firstSample = body.slice(Math.max(0, idx - 12), Math.min(body.length, idx + phrase.length + 12)).trim();
|
|
56012
56066
|
}
|
|
56013
56067
|
from = idx + phrase.length;
|
|
56014
56068
|
}
|
|
56015
56069
|
}
|
|
56016
|
-
if (
|
|
56070
|
+
if (count2 === 0) return [];
|
|
56017
56071
|
return [
|
|
56018
56072
|
{
|
|
56019
56073
|
id: "marketing-buzzword",
|
|
56020
|
-
snippet: `${
|
|
56074
|
+
snippet: `${count2} buzzword phrase${count2 === 1 ? "" : "s"}: "${firstSample}"`,
|
|
56021
56075
|
file
|
|
56022
56076
|
}
|
|
56023
56077
|
];
|
|
@@ -56029,18 +56083,18 @@ var ANALYZERS = [
|
|
|
56029
56083
|
scope: "page",
|
|
56030
56084
|
run: (text2, file) => {
|
|
56031
56085
|
const body = stripHtmlToText(text2);
|
|
56032
|
-
let
|
|
56086
|
+
let count2 = 0;
|
|
56033
56087
|
let firstSample = "";
|
|
56034
56088
|
for (const m of body.matchAll(/\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g)) {
|
|
56035
|
-
|
|
56089
|
+
count2++;
|
|
56036
56090
|
if (!firstSample) firstSample = cap2(m, 0).trim().slice(0, 80);
|
|
56037
56091
|
}
|
|
56038
56092
|
for (const m of body.matchAll(/\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g)) {
|
|
56039
|
-
|
|
56093
|
+
count2++;
|
|
56040
56094
|
if (!firstSample) firstSample = cap2(m, 0).trim().slice(0, 80);
|
|
56041
56095
|
}
|
|
56042
|
-
if (
|
|
56043
|
-
return [{ id: "aphoristic-cadence", snippet: `${
|
|
56096
|
+
if (count2 < 3) return [];
|
|
56097
|
+
return [{ id: "aphoristic-cadence", snippet: `${count2} aphoristic constructions: "${firstSample}"`, file }];
|
|
56044
56098
|
}
|
|
56045
56099
|
},
|
|
56046
56100
|
// Shared AI copy tells (engine/copy-tells) — the Wikipedia "Signs of AI
|
|
@@ -62344,23 +62398,24 @@ function narrowToFamily(commandName, available) {
|
|
|
62344
62398
|
var schemaCommand2 = defineCommand204({
|
|
62345
62399
|
meta: {
|
|
62346
62400
|
name: "schema",
|
|
62347
|
-
description: "Inspect command argument schemas (for AI agent introspection). Lists all commands if no argument given. Example: baker schema images.search"
|
|
62401
|
+
description: "Inspect command argument schemas (for AI agent introspection). Lists all commands if no argument given. Example: baker schema images search (or images.search)"
|
|
62348
62402
|
},
|
|
62349
62403
|
args: {
|
|
62350
62404
|
command: {
|
|
62351
62405
|
type: "positional",
|
|
62352
|
-
description: "Command
|
|
62406
|
+
description: "Command path, as typed (images list) or dotted (images.list). Omit to list all.",
|
|
62353
62407
|
required: false
|
|
62354
62408
|
}
|
|
62355
62409
|
},
|
|
62356
62410
|
run: ({ args: args4 }) => {
|
|
62357
|
-
const
|
|
62358
|
-
if (
|
|
62411
|
+
const words2 = args4._.filter((word) => typeof word === "string" && word.length > 0);
|
|
62412
|
+
if (words2.length === 0) {
|
|
62359
62413
|
const schemas = listSchemas();
|
|
62360
62414
|
writeJson({ ok: true, data: { commands: schemas } });
|
|
62361
62415
|
return;
|
|
62362
62416
|
}
|
|
62363
|
-
const
|
|
62417
|
+
const commandName = words2.join(".");
|
|
62418
|
+
const schema = findSchema(words2);
|
|
62364
62419
|
if (!schema) {
|
|
62365
62420
|
const all = listSchemas();
|
|
62366
62421
|
const available = narrowToFamily(commandName, all);
|
|
@@ -62810,7 +62865,7 @@ function clipImageLimitFor(args4) {
|
|
|
62810
62865
|
if (args4.from !== "references") {
|
|
62811
62866
|
return {
|
|
62812
62867
|
max: 1,
|
|
62813
|
-
refuse: (
|
|
62868
|
+
refuse: (count2) => `Animating one exact frame uses a single image \u2014 you passed ${count2}. Pick one, or switch --from references to guide the shot with all of them.`
|
|
62814
62869
|
};
|
|
62815
62870
|
}
|
|
62816
62871
|
if (!supportsReferenceToVideo(model)) {
|
|
@@ -62823,7 +62878,7 @@ function clipImageLimitFor(args4) {
|
|
|
62823
62878
|
if (max === void 0) return void 0;
|
|
62824
62879
|
return {
|
|
62825
62880
|
max,
|
|
62826
|
-
refuse: (
|
|
62881
|
+
refuse: (count2) => `${label} takes up to ${max} reference images \u2014 you passed ${count2}. Drop the weakest ones.`
|
|
62827
62882
|
};
|
|
62828
62883
|
}
|
|
62829
62884
|
function clipModeFor(from) {
|
|
@@ -62848,7 +62903,7 @@ function buildAnimateBody(args4, prompt, imageIds) {
|
|
|
62848
62903
|
return body;
|
|
62849
62904
|
}
|
|
62850
62905
|
function costHintsFor(body) {
|
|
62851
|
-
const
|
|
62906
|
+
const count2 = body.count ?? 1;
|
|
62852
62907
|
const model = body.model ?? DEFAULT_VIDEO_GENERATE_MODEL;
|
|
62853
62908
|
const perTake = estimateVideoCredits({
|
|
62854
62909
|
model,
|
|
@@ -62865,9 +62920,9 @@ function costHintsFor(body) {
|
|
|
62865
62920
|
// that gets re-routed — a frame-started clip never does.
|
|
62866
62921
|
mayRouteToFal: body.clipMode === "references" && body.imageIds.length > 0
|
|
62867
62922
|
});
|
|
62868
|
-
const total = perTake *
|
|
62923
|
+
const total = perTake * count2;
|
|
62869
62924
|
const hints2 = [
|
|
62870
|
-
|
|
62925
|
+
count2 === 1 ? `This clip costs about ${total} credits \u2014 roughly 45x an image.` : `${count2} takes at about ${perTake} credits each \u2014 about ${total} credits.`
|
|
62871
62926
|
];
|
|
62872
62927
|
const pinned = pinnedModelHint({ avatar: body.avatar, model: body.model, generateAudio: body.generateAudio });
|
|
62873
62928
|
if (pinned) hints2.push(pinned);
|
|
@@ -62875,7 +62930,7 @@ function costHintsFor(body) {
|
|
|
62875
62930
|
if (pace) hints2.push(pace);
|
|
62876
62931
|
const silent = silentModelHint(model, body.prompt);
|
|
62877
62932
|
if (silent) hints2.push(silent);
|
|
62878
|
-
if (
|
|
62933
|
+
if (count2 > 1) {
|
|
62879
62934
|
hints2.push(
|
|
62880
62935
|
"Ask for more than one take only when the MOTION is the risk \u2014 for a look you are unsure about, iterate on the still first."
|
|
62881
62936
|
);
|
|
@@ -63128,7 +63183,7 @@ function referenceLimitFor(model) {
|
|
|
63128
63183
|
const label = MODEL_REGISTRY.image_generate[chosen]?.label ?? chosen;
|
|
63129
63184
|
return {
|
|
63130
63185
|
max,
|
|
63131
|
-
refuse: (
|
|
63186
|
+
refuse: (count2) => max === 1 ? `${label} takes exactly one reference image \u2014 you passed ${count2}. Pick the one that matters, or switch to a model that reads several.` : `${label} takes up to ${max} reference images \u2014 you passed ${count2}. Drop the weakest ones.`
|
|
63132
63187
|
};
|
|
63133
63188
|
}
|
|
63134
63189
|
async function effectiveModel(pinned, skillId, fetchSkills = () => apiGet("/api/studio/skills")) {
|
|
@@ -66953,7 +67008,7 @@ function commandFor(root, argv) {
|
|
|
66953
67008
|
const { command, resolved } = resolveCommand2(root, argv);
|
|
66954
67009
|
return resolved ? command : null;
|
|
66955
67010
|
}
|
|
66956
|
-
function unknownFlagEnvelope(unknown, commandPath, suggestion) {
|
|
67011
|
+
function unknownFlagEnvelope(unknown, commandPath, suggestion, hasSchema) {
|
|
66957
67012
|
const list = unknown.join(", ");
|
|
66958
67013
|
return {
|
|
66959
67014
|
ok: false,
|
|
@@ -66961,8 +67016,8 @@ function unknownFlagEnvelope(unknown, commandPath, suggestion) {
|
|
|
66961
67016
|
code: "UNKNOWN_ARGUMENT",
|
|
66962
67017
|
message: `${list} ${unknown.length > 1 ? "are not options" : "is not an option"} of \`baker ${commandPath}\`.`,
|
|
66963
67018
|
fix: {
|
|
66964
|
-
action: "check_schema",
|
|
66965
|
-
explanation: (suggestion ? `Did you mean ${suggestion}? ` : "") + `Run
|
|
67019
|
+
action: hasSchema ? "check_schema" : "check_help",
|
|
67020
|
+
explanation: (suggestion ? `Did you mean ${suggestion}? ` : "") + `Run \`${hasSchema ? `baker schema ${commandPath}` : `baker ${commandPath} --help`}\` to see every option this command takes. Do not re-run the same call: until this version an unrecognised option was accepted and ignored, so a command that looked like it had filtered or narrowed the result was in fact answering the unfiltered question.`
|
|
66966
67021
|
},
|
|
66967
67022
|
retryable: false
|
|
66968
67023
|
}
|
|
@@ -66993,8 +67048,9 @@ function refuseUnknownFlags(root, argv) {
|
|
|
66993
67048
|
const command = commandFor(root, argv);
|
|
66994
67049
|
const first = unknown[0];
|
|
66995
67050
|
const suggestion = command && first ? closestFlag(first, command) : null;
|
|
67051
|
+
const path48 = commandPathOf(root, argv);
|
|
66996
67052
|
process.stdout.write(
|
|
66997
|
-
`${JSON.stringify(unknownFlagEnvelope(unknown,
|
|
67053
|
+
`${JSON.stringify(unknownFlagEnvelope(unknown, path48, suggestion, findSchema(path48.split(" ")) !== void 0), null, 2)}
|
|
66998
67054
|
`
|
|
66999
67055
|
);
|
|
67000
67056
|
process.exit(1);
|