@koda-sl/baker-cli 0.440.0-dev.dab0aaba9 → 0.443.0-dev.edb8d8767

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 CHANGED
@@ -5832,7 +5832,7 @@ offered, or, for a viewer who does not know the brand, when the words say they a
5832
5832
  cart, a trial, a donation, a registration); who it is for is reported but never fails an ad alone. The viewer is the `viewer` recorded in
5833
5833
  `_definition.md`, else the one its `targetAdSet` reaches (as `baker ads meta adsets --audience` reads it), else
5834
5834
  the persona and `awareness` from the Knowledge, else a cold stranger. Not understood → exit 2 with
5835
- `viewer_check_failed` and a `fix` naming what the viewer missed. Answers are cached per copy, scene and viewer.
5835
+ `viewer_check_failed` and a `fix` that starts with the one rewrite answering a missed offer (`Do this: make the biggest words say "<message>"`) and names what the viewer missed. After two failures of one ad in a chat turn the check no longer holds it back: the command answers `ok: true` with `understood: false` and what was missed, and `baker canvas run` renders it, printing the miss. The casting check is asked alongside the viewer. Answers are cached per copy, scene and viewer.
5836
5836
  `baker canvas run` makes the same check on a static ad before it spends (skipped for `--fix`, an autofix round, `--use-variant`, a re-layout and resumed runs, never for an edit of the words;
5837
5837
  `--skip-viewer-check` renders without it; a model outage lets the render through with a warning). `--full` adds
5838
5838
  the viewer's whole answer and how the viewer was chosen.
@@ -5879,7 +5879,7 @@ A completed render the review calls ready (`verdict` `ready` or `ready_with_note
5879
5879
 
5880
5880
  **Resuming an interrupted run.** A long `baker canvas run` (multi-clip video) that is killed mid-render — session end, sandbox pause — leaves a marker under the outputs dir. The next `baker canvas run` of the same canvas automatically **resumes** that run: it reuses the run id so still-running billed jobs re-attach instead of being abandoned and re-billed, and completed nodes come from the cache. A live render keeps its marker's heartbeat fresh, so a second `run` of the same canvas is refused while it is alive (checked with `kill(pid, 0)` plus the heartbeat, on Linux and macOS alike). Resume also works from a **different workspace or a fresh sandbox**: when no local marker survives, the run history is consulted for a resumable run of the same source (canvas, `prompt.json`, scenes, compositions and formats), and it is adopted when the backend says it may be: interrupted, stopped at a checkpoint, or a `running` row whose keepalive is older than the backend's staleness threshold. Ctrl-C / SIGTERM aborts gracefully — no new nodes dispatch, a resumable snapshot is flushed, and the marker survives. A clean completion (or a handled failure) clears the marker, so a normal re-run starts a fresh generation. Force a new run with `--fresh`, or pin a specific run with `--run-id <id>` (also the escape hatch to adopt a run that is reported as concurrently live). Independent same-layer nodes (e.g. video clips) fan out in parallel up to `--parallel`/`--concurrency` (default 8; env `BAKER_CANVAS_CONCURRENCY`).
5881
5881
 
5882
- **Static ads: the run corrects a blocking defect itself.** When the review of a static ad finds a blocking defect, `baker canvas run` makes up to 2 more renders (`AUTO_FIX_ROUNDS` in `static-autofix.ts`) before it reports, correcting each defect at the layer that causes it: words or a logo where an interface covers them (Reels, Stories) or the frame cuts them are **placed and extended** (`withExtendedSizes`): a `static_place` node reads where everything sits and shrinks the ad (to 60% at most) into the size's safe area, and a `static_extend_merge` node has the ad's own model paint only the margins that leaves and lays the ad's own pixels back over the painting, so the model is never asked to move anything (a size already inside its area is passed on with nothing painted or billed, and one whose words sit too far out for a shrink to clear is laid out again instead); a defect the spec asked for (a second subject, stars with no named source, a concept that does not read) and any defect of a native screen corrects the spec first (`static-spec-repair.ts`: a model rewrites `prompt.json` for those findings alone, every line of the copy kept word for word), then draws the ad again from it (a native screen redraws free); a picture wrong as a whole or in three places or more is made again from its brief, told what the last one got wrong, but only in a round with another after it unless the brief caused the defect; anything else on the picture is a `--fix` of the chosen picture for its own findings (`correctionOf`: the words to erase, the line to re-letter or add), naming what the last edit was already asked for, while each re-laid size follows as an edit of its own last layout (`withSizesFollowingFix`), keeping what an earlier round fixed in it; anything else on a re-laid size alone edits just that size from its own last layout (`withRelayEdit`). A size a round did not touch (its file the same byte for byte) keeps the round before's verdict (`carryUnchanged`), so the review's second look at the same pixels never counts as a new defect. Each round is one render (a fix, or one request per re-laid size) and prints `[autofix] round N of 2: …` on stdout; the final JSON's hints start with "Corrected automatically before this result, …" and its review is of the corrected version. Blocking findings left after the rounds keep the hints below. A round that would only repeat the correction that just left its defect in place (a chosen option, `--use-variant`, is only ever corrected) is not run: that version is reported as it is. `--no-autofix` reports after the first render.
5882
+ **Static ads: the run corrects a blocking defect itself.** When the review of a static ad finds a blocking defect, `baker canvas run` makes up to 2 more renders (`AUTO_FIX_ROUNDS` in `static-autofix.ts`) before it reports, correcting each defect at the layer that causes it: words or a logo where an interface covers them (Reels, Stories) or the frame cuts them are **placed and extended** (`withExtendedSizes`): a `static_place` node reads where everything sits and shrinks the ad (to 60% at most) into the size's safe area, and a `static_extend_merge` node has the ad's own model paint only the margins that leaves and lays the ad's own pixels back over the painting, so the model is never asked to move anything (a size already inside its area is passed on with nothing painted or billed, and one whose words sit too far out for a shrink to clear is laid out again instead); a defect the spec asked for (a second subject, stars with no named source, a concept that does not read) and any defect of a native screen corrects the spec first (`static-spec-repair.ts`: a model rewrites `prompt.json` for those findings alone, every line of the copy kept word for word), then draws the ad again from it (a native screen redraws free); a picture wrong as a whole or in three places or more is made again from its brief, told what the last one got wrong, but only in a round with another after it unless the brief caused the defect; anything else on the picture is a `--fix` of the chosen picture for its own findings (`correctionOf`: the words to erase, the line to re-letter or add), naming what the last edit was already asked for, while each re-laid size the review flagged follows as an edit of its own last layout (`withSizesFollowingFix`), keeping what an earlier round fixed in it, and a size it found nothing blocking on is delivered as it was (`keep`, nothing in it made again or placed); anything else on a re-laid size alone edits just that size from its own last layout (`withRelayEdit`). A size a round did not touch (its file the same byte for byte) keeps the round before's verdict (`carryUnchanged`) and is not read again: the review reads only the sizes the round changed. Each round is one render (a fix, or one request per re-laid size) and prints `[autofix] round N of 2: …` on stdout; the final JSON's hints start with "Corrected automatically before this result, …" and its review is of the corrected version. Blocking findings left after the rounds keep the hints below. A round that would only repeat the correction that just left its defect in place (a chosen option, `--use-variant`, is only ever corrected) is not run: that version is reported as it is. `--no-autofix` reports after the first render.
5883
5883
 
5884
5884
  **Static ads: correct a version, or use another of its variants.** Correct a finished version with `--fix "<what to change>"`: one render takes that version (the last one, or `--from <run_id>`) as its first reference and changes only what you said, keeping the picture, the layout and every other word; it replaces the variants and the judge, and every size is made again from the corrected picture. **A `prompt.json` changed only in its words, their positions or its colours** since the version shown is made the same way, as an edit of that version (`[edit]` in the output, the instruction read off the change); three new options are asked for only with `--regenerate static_variants`. A `--fix` whose defect is still blocking afterwards is recorded, and asking for it again with the same words exits 2 with `no_progress`. When every blocking finding is on re-laid-out sizes, the first hint says to lay out those sizes again (`--regenerate adapt_<size>`) before any `--fix`. Every static run's hints say what it cost in credits and what for. There is no render limit per creative. Use another variant with `--use-variant <sha256>` (`--from <run_id>`, default the last render): when the person chose another of a version's variants over the judge's pick, every size is made again from that picture, and the only requests re-lay it out for each other size; `--regenerate adapt_9x16` lays out one size again from the same chosen picture when only that size came out wrong (after a `--fix` or `--use-variant`, from that corrected or chosen picture, so the size never brings back the uncorrected one; `--from <run_id>` names another version); `baker creatives feedback --slug <slug>` prints the choice (`variantChoice`) and the exact command.
5885
5885
 
package/dist/cli.js CHANGED
@@ -73631,11 +73631,18 @@ function autoFixStep(canvas, review, opts = {}) {
73631
73631
  pictureFindings.filter((f) => !BRIEF.test(withoutSize(f.what))).flatMap((f) => correctionOf(f.what) ?? [])
73632
73632
  );
73633
73633
  if (changes.length === 0) return sizesOnly();
73634
+ const flagged = new Set(blocking.flatMap((f) => sizesOf(f.what)));
73635
+ const keep = relaid.map((r) => r.format).filter((format) => !flagged.has(format));
73636
+ const remade = every.filter((size) => !keep.includes(size));
73637
+ const kept = {
73638
+ ...keep.length > 0 ? { keep } : {},
73639
+ ...remade.length > 0 ? { extend: remade } : {}
73640
+ };
73634
73641
  const asked = `Change only this: ${changes.join(" ")}`;
73635
73642
  const again = onPicture.filter((w) => persisting.has(w)).map((w) => w.replace(CATEGORY, "").replace(/\.$/, ""));
73636
73643
  const instruction = again.length > 0 ? `${asked} The last edit was already asked for this and Image 1 still shows it, so make it clearly: ${again.join("; ")}.` : asked;
73637
73644
  if (repeatsFailedFix(opts.lastFix, asked) || repeatsFailedFix(opts.lastFix, instruction)) return null;
73638
- return { kind: "fix", instruction, ...Object.keys(edits).length > 0 ? { relay: edits } : {}, ...afterNewPicture };
73645
+ return { kind: "fix", instruction, ...Object.keys(edits).length > 0 ? { relay: edits } : {}, ...kept };
73639
73646
  }
73640
73647
  var unique = (lines) => [...new Set(lines)];
73641
73648
  function deliveredSizes(canvas) {
@@ -73936,19 +73943,19 @@ async function variantImage(canvasRoot, runId, sha2562, fetchRecord = fetchRunRe
73936
73943
  }
73937
73944
 
73938
73945
  // src/commands/canvas/static-run-review.ts
73939
- import { readFile as readFile23 } from "fs/promises";
73946
+ import { readFile as readFile24 } from "fs/promises";
73940
73947
 
73941
73948
  // src/commands/canvas/glance-check.ts
73942
73949
  import sharp2 from "sharp";
73943
- import { z as z65 } from "zod";
73950
+ import { z as z66 } from "zod";
73944
73951
 
73945
73952
  // src/commands/canvas/viewer-check.ts
73946
73953
  import { createHash as createHash4 } from "crypto";
73947
- import { mkdir as mkdir8, readdir as readdir12, readFile as readFile22, stat as stat4, writeFile as writeFile13 } from "fs/promises";
73948
- import path28 from "path";
73954
+ import { mkdir as mkdir9, readdir as readdir12, readFile as readFile23, stat as stat4, writeFile as writeFile14 } from "fs/promises";
73955
+ import path29 from "path";
73949
73956
  import { defineCommand as defineCommand138 } from "citty";
73950
73957
  import { parse as parseYaml3 } from "yaml";
73951
- import { z as z64 } from "zod";
73958
+ import { z as z65 } from "zod";
73952
73959
 
73953
73960
  // src/commands/canvas/unbilled-spend.ts
73954
73961
  import { randomUUID as randomUUID2 } from "crypto";
@@ -74082,17 +74089,76 @@ function castingCheckFailure(answer, slug) {
74082
74089
  };
74083
74090
  }
74084
74091
 
74092
+ // src/commands/canvas/turn-renders.ts
74093
+ import { mkdir as mkdir8, readFile as readFile22, writeFile as writeFile13 } from "fs/promises";
74094
+ import path28 from "path";
74095
+ import { z as z64 } from "zod";
74096
+ var RUNS_PER_TURN = 2;
74097
+ var VIEWER_FAILS_PER_TURN = 2;
74098
+ var LedgerSchema = z64.object({
74099
+ turnId: z64.string(),
74100
+ creatives: z64.record(
74101
+ z64.string(),
74102
+ z64.object({ runs: z64.number().default(0), verdict: z64.string().optional(), viewerFails: z64.number().optional() })
74103
+ )
74104
+ });
74105
+ function turnRenderRefusal(ledger, turnId, slug) {
74106
+ const entry = turnId !== void 0 && ledger?.turnId === turnId ? ledger.creatives[slug] : void 0;
74107
+ if (!entry) return null;
74108
+ if (entry.verdict === "ready" || entry.verdict === "ready_with_notes") {
74109
+ return {
74110
+ code: "CONFLICT",
74111
+ message: `This ad is already ready in this turn (${entry.verdict}): its notes are not defects.`,
74112
+ fix: "Show it now. A change the person asks for comes with their next message."
74113
+ };
74114
+ }
74115
+ if (entry.runs >= RUNS_PER_TURN) {
74116
+ return {
74117
+ code: "CONFLICT",
74118
+ message: `This ad was rendered twice in this turn, its automatic corrections included, and is still not ready.`,
74119
+ fix: "Show the best version and say in one plain sentence what is left, in the person's words; they decide what changes next."
74120
+ };
74121
+ }
74122
+ return null;
74123
+ }
74124
+ function recordTurnRender(ledger, turnId, slug, verdict2) {
74125
+ const creatives = ledger?.turnId === turnId ? ledger.creatives : {};
74126
+ return { turnId, creatives: { ...creatives, [slug]: { ...creatives[slug], runs: (creatives[slug]?.runs ?? 0) + 1, verdict: verdict2 } } };
74127
+ }
74128
+ function recordViewerFail(ledger, turnId, slug) {
74129
+ const creatives = ledger?.turnId === turnId ? ledger.creatives : {};
74130
+ const entry = creatives[slug] ?? { runs: 0 };
74131
+ return { turnId, creatives: { ...creatives, [slug]: { ...entry, viewerFails: (entry.viewerFails ?? 0) + 1 } } };
74132
+ }
74133
+ function viewerChecksSpent(ledger, turnId, slug) {
74134
+ const entry = turnId !== void 0 && ledger?.turnId === turnId ? ledger.creatives[slug] : void 0;
74135
+ return (entry?.viewerFails ?? 0) >= VIEWER_FAILS_PER_TURN;
74136
+ }
74137
+ var ledgerPath = (cacheDir) => path28.join(cacheDir, "turn-renders.json");
74138
+ async function readTurnRenders(cacheDir) {
74139
+ try {
74140
+ const parsed = LedgerSchema.safeParse(JSON.parse(await readFile22(ledgerPath(cacheDir), "utf8")));
74141
+ return parsed.success ? parsed.data : void 0;
74142
+ } catch {
74143
+ return void 0;
74144
+ }
74145
+ }
74146
+ async function writeTurnRenders(cacheDir, ledger) {
74147
+ await mkdir8(cacheDir, { recursive: true });
74148
+ await writeFile13(ledgerPath(cacheDir), JSON.stringify(ledger), "utf8");
74149
+ }
74150
+
74085
74151
  // src/commands/canvas/viewer-check.ts
74086
74152
  var VIEWER_QUESTIONS = ["offer", "for_whom", "next_step"];
74087
74153
  var VIEWER_CLARITY = ["clear", "guess", "unclear"];
74088
- var ViewerReply = z64.object({ answer: z64.string().default(""), clarity: z64.enum(VIEWER_CLARITY) });
74089
- var ViewerAnswerSchema = z64.object({
74154
+ var ViewerReply = z65.object({ answer: z65.string().default(""), clarity: z65.enum(VIEWER_CLARITY) });
74155
+ var ViewerAnswerSchema = z65.object({
74090
74156
  offer: ViewerReply,
74091
74157
  for_whom: ViewerReply,
74092
74158
  next_step: ViewerReply,
74093
74159
  /** The words that talk to the viewer as if they had already done something with the brand; "" when none. */
74094
- presumes: z64.string().default(""),
74095
- missing: z64.string().default("")
74160
+ presumes: z65.string().default(""),
74161
+ missing: z65.string().default("")
74096
74162
  });
74097
74163
  var VIEWER_REQUIRED_CLARITY = {
74098
74164
  offer: "clear",
@@ -74199,14 +74265,14 @@ function viewerCheckCacheKey(input, model) {
74199
74265
  }
74200
74266
  async function cachedAnswer(file) {
74201
74267
  try {
74202
- const parsed = ViewerAnswerSchema.safeParse(JSON.parse(await readFile22(file, "utf8")));
74268
+ const parsed = ViewerAnswerSchema.safeParse(JSON.parse(await readFile23(file, "utf8")));
74203
74269
  return parsed.success ? parsed.data : null;
74204
74270
  } catch {
74205
74271
  return null;
74206
74272
  }
74207
74273
  }
74208
74274
  async function checkViewer(input, opts) {
74209
- const file = path28.join(opts.cacheDir, "viewer-check", `${viewerCheckCacheKey(input, opts.model)}.json`);
74275
+ const file = path29.join(opts.cacheDir, "viewer-check", `${viewerCheckCacheKey(input, opts.model)}.json`);
74210
74276
  let answer = await cachedAnswer(file);
74211
74277
  const cached = answer !== null;
74212
74278
  const prompt = buildViewerPrompt(input);
@@ -74216,8 +74282,8 @@ async function checkViewer(input, opts) {
74216
74282
  }
74217
74283
  if (answer === null) return { status: "unavailable", input };
74218
74284
  if (!cached) {
74219
- await mkdir8(path28.dirname(file), { recursive: true });
74220
- await writeFile13(file, `${JSON.stringify(answer, null, 2)}
74285
+ await mkdir9(path29.dirname(file), { recursive: true });
74286
+ await writeFile14(file, `${JSON.stringify(answer, null, 2)}
74221
74287
  `, "utf8");
74222
74288
  }
74223
74289
  const verdict2 = judgeViewerAnswer(answer, input.intended);
@@ -74248,7 +74314,10 @@ function viewerCheckFailure(outcome2, slug) {
74248
74314
  const who = describeViewer(viewer);
74249
74315
  const missing = outcome2.answer.missing.trim();
74250
74316
  const unsaid = outcome2.verdict.missed.flatMap((m) => m.question === "presumes" ? [] : [QUESTION_WORDS[m.question]]);
74317
+ const offer = outcome2.input.intended.offer?.trim();
74318
+ const rewrite = offer && outcome2.verdict.missed.some((m) => m.question === "offer") ? [`Do this: make the biggest words say "${offer}", plainly, in the ad's language; keep the rest.`] : [];
74251
74319
  const fix = [
74320
+ ...rewrite,
74252
74321
  ...unsaid.length > 0 ? [`Seen by ${who}, the words and picture alone do not say ${unsaid.join(" or ")}.`] : [`Seen by ${who}:`],
74253
74322
  ...outcome2.verdict.missed.map(missLine),
74254
74323
  ...missing ? [`They needed: ${missing}`] : [],
@@ -74267,17 +74336,17 @@ function viewerCheckFailure(outcome2, slug) {
74267
74336
  }
74268
74337
  };
74269
74338
  }
74270
- var RecordedViewer = z64.object({
74271
- temperature: z64.enum(VIEWER_TEMPERATURE_VALUES),
74272
- knows: z64.array(z64.enum(VIEWER_KNOWS_VALUES)).default([]),
74273
- placements: z64.array(z64.string().min(1)).default(["automatic"]),
74274
- source: z64.enum(VIEWER_SOURCE_VALUES),
74275
- reasons: z64.array(z64.string()).default([]),
74276
- market: z64.object({
74277
- country: z64.string().min(2),
74278
- language: z64.string().optional(),
74279
- currency: z64.string().optional(),
74280
- address: z64.string().optional()
74339
+ var RecordedViewer = z65.object({
74340
+ temperature: z65.enum(VIEWER_TEMPERATURE_VALUES),
74341
+ knows: z65.array(z65.enum(VIEWER_KNOWS_VALUES)).default([]),
74342
+ placements: z65.array(z65.string().min(1)).default(["automatic"]),
74343
+ source: z65.enum(VIEWER_SOURCE_VALUES),
74344
+ reasons: z65.array(z65.string()).default([]),
74345
+ market: z65.object({
74346
+ country: z65.string().min(2),
74347
+ language: z65.string().optional(),
74348
+ currency: z65.string().optional(),
74349
+ address: z65.string().optional()
74281
74350
  }).optional()
74282
74351
  });
74283
74352
  var nameKey = (text3) => wordsOf(text3).join(" ");
@@ -74336,12 +74405,12 @@ async function resolveViewerAlone(sources, lookups) {
74336
74405
  notes
74337
74406
  };
74338
74407
  }
74339
- var Frontmatter = z64.object({
74340
- viewer: z64.unknown().optional(),
74341
- targetAdSet: z64.union([z64.string(), z64.number()]).transform(String).optional(),
74342
- persona: z64.string().optional(),
74343
- awareness: z64.enum(AWARENESS_VALUES).optional().catch(void 0),
74344
- angle: z64.string().optional()
74408
+ var Frontmatter = z65.object({
74409
+ viewer: z65.unknown().optional(),
74410
+ targetAdSet: z65.union([z65.string(), z65.number()]).transform(String).optional(),
74411
+ persona: z65.string().optional(),
74412
+ awareness: z65.enum(AWARENESS_VALUES).optional().catch(void 0),
74413
+ angle: z65.string().optional()
74345
74414
  });
74346
74415
  function frontmatterOf2(raw) {
74347
74416
  const block2 = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
@@ -74354,8 +74423,8 @@ function frontmatterOf2(raw) {
74354
74423
  }
74355
74424
  }
74356
74425
  async function readCreativeForViewerCheck(creativeDir) {
74357
- const spec = readAdSpec(JSON.parse(await readFile22(path28.join(creativeDir, "prompt.json"), "utf8")));
74358
- const definition = frontmatterOf2(await readFile22(path28.join(creativeDir, "_definition.md"), "utf8").catch(() => ""));
74426
+ const spec = readAdSpec(JSON.parse(await readFile23(path29.join(creativeDir, "prompt.json"), "utf8")));
74427
+ const definition = frontmatterOf2(await readFile23(path29.join(creativeDir, "_definition.md"), "utf8").catch(() => ""));
74359
74428
  const offer = spec.message?.trim() || definition.angle?.trim();
74360
74429
  const persona = spec.viewer?.who.trim() || definition.persona?.trim();
74361
74430
  const brand = spec.brand?.name?.trim();
@@ -74384,7 +74453,7 @@ function castPeople(spec) {
74384
74453
  return people.length > 0 ? { people } : {};
74385
74454
  }
74386
74455
  async function readPersonas(workspaceRoot) {
74387
- const dir = path28.join(workspaceRoot, "src", "content", "personas");
74456
+ const dir = path29.join(workspaceRoot, "src", "content", "personas");
74388
74457
  let files;
74389
74458
  try {
74390
74459
  files = (await readdir12(dir, { recursive: true })).filter((f) => f.endsWith(".md"));
@@ -74393,7 +74462,7 @@ async function readPersonas(workspaceRoot) {
74393
74462
  }
74394
74463
  return await Promise.all(
74395
74464
  files.map(async (file) => {
74396
- const raw = await readFile22(path28.join(dir, file), "utf8");
74465
+ const raw = await readFile23(path29.join(dir, file), "utf8");
74397
74466
  const block2 = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
74398
74467
  let segment;
74399
74468
  try {
@@ -74403,7 +74472,7 @@ async function readPersonas(workspaceRoot) {
74403
74472
  }
74404
74473
  const title = raw.match(/^#\s+(.+)$/m)?.[1]?.trim();
74405
74474
  return {
74406
- slug: path28.basename(file, ".md"),
74475
+ slug: path29.basename(file, ".md"),
74407
74476
  ...title ? { title } : {},
74408
74477
  ...typeof segment === "string" && segment.trim() ? { segment: segment.trim() } : {}
74409
74478
  };
@@ -74442,8 +74511,8 @@ function liveViewerLookups(workspaceRoot) {
74442
74511
  return {
74443
74512
  adSetViewer: metaAdSetViewer,
74444
74513
  personas: () => readPersonas(workspaceRoot),
74445
- companyMarket: async () => marketFromCompanyDoc(await readFile22(path28.join(workspaceRoot, "COMPANY.md"), "utf8").catch(() => null)),
74446
- company: async () => (await readFile22(path28.join(workspaceRoot, "COMPANY.md"), "utf8").catch(() => "")).slice(0, 800)
74514
+ companyMarket: async () => marketFromCompanyDoc(await readFile23(path29.join(workspaceRoot, "COMPANY.md"), "utf8").catch(() => null)),
74515
+ company: async () => (await readFile23(path29.join(workspaceRoot, "COMPANY.md"), "utf8").catch(() => "")).slice(0, 800)
74447
74516
  };
74448
74517
  }
74449
74518
  function gatewayViewerModel(model, onSpend) {
@@ -74473,16 +74542,7 @@ async function viewerCheckBeforeRender(opts) {
74473
74542
  const who = describeViewer(resolved.viewer);
74474
74543
  const lines = resolved.notes.map((note) => `[viewer] ${note}`);
74475
74544
  if (opts.skip) return { viewer: resolved.viewer, lines: [...lines, `[viewer] not asked: ${SKIP_WORDS[opts.skip]}.`] };
74476
- const outcome2 = await checkViewer({ ...creative.ad, viewer: resolved.viewer }, opts);
74477
- if (outcome2.status === "unavailable") {
74478
- lines.push(
74479
- `[warn] viewer_check_unavailable: the viewer model did not answer, so this ad renders without the viewer check. Read the copy as ${who} before showing it, or run \`baker canvas viewer-check ${opts.slug}\` again.`
74480
- );
74481
- return { viewer: resolved.viewer, lines };
74482
- }
74483
- if (outcome2.status === "failed") return { viewer: resolved.viewer, lines, failure: viewerCheckFailure(outcome2, opts.slug) };
74484
- lines.push(`[viewer] ${who} understood it: ${outcome2.answer.offer.answer}${outcome2.cached ? " (cached)" : ""}.`);
74485
- const casting = await castingCheck(
74545
+ const casting = (async () => castingCheck(
74486
74546
  {
74487
74547
  people: creative.ad.people ?? [],
74488
74548
  company: await opts.lookups.company?.().catch(() => "") ?? "",
@@ -74490,10 +74550,26 @@ async function viewerCheckBeforeRender(opts) {
74490
74550
  ...creative.ad.intended.persona ? { persona: creative.ad.intended.persona } : {}
74491
74551
  },
74492
74552
  { cacheDir: opts.cacheDir, model: opts.model, ask: opts.ask }
74493
- );
74494
- if (casting.status === "failed") return { viewer: resolved.viewer, lines, failure: castingCheckFailure(casting.answer, opts.slug) };
74495
- if (casting.status === "passed") lines.push(`[casting] the people cast pass as real customers${casting.cached ? " (cached)" : ""}.`);
74496
- if (casting.status === "unavailable") {
74553
+ ))().catch(() => ({ status: "unavailable" }));
74554
+ const outcome2 = await checkViewer({ ...creative.ad, viewer: resolved.viewer }, opts);
74555
+ if (outcome2.status === "unavailable") {
74556
+ lines.push(
74557
+ `[warn] viewer_check_unavailable: the viewer model did not answer, so this ad renders without the viewer check. Read the copy as ${who} before showing it, or run \`baker canvas viewer-check ${opts.slug}\` again.`
74558
+ );
74559
+ } else if (outcome2.status === "failed" && !opts.spent) {
74560
+ return { viewer: resolved.viewer, lines, failure: viewerCheckFailure(outcome2, opts.slug) };
74561
+ } else if (outcome2.status === "failed") {
74562
+ lines.push(
74563
+ `[viewer] this ad failed the viewer check twice in this turn, so it renders as written. Tell the person what ${who} missed: ${outcome2.verdict.missed.map(missLine).join(" ")}`
74564
+ );
74565
+ } else {
74566
+ lines.push(`[viewer] ${who} understood it: ${outcome2.answer.offer.answer}${outcome2.cached ? " (cached)" : ""}.`);
74567
+ }
74568
+ if (outcome2.status === "unavailable") return { viewer: resolved.viewer, lines };
74569
+ const cast = await casting;
74570
+ if (cast.status === "failed") return { viewer: resolved.viewer, lines, failure: castingCheckFailure(cast.answer, opts.slug) };
74571
+ if (cast.status === "passed") lines.push(`[casting] the people cast pass as real customers${cast.cached ? " (cached)" : ""}.`);
74572
+ if (cast.status === "unavailable") {
74497
74573
  lines.push("[warn] casting_check_unavailable: the model did not answer; check yourself that the people cast look like this company's real customers.");
74498
74574
  }
74499
74575
  return { viewer: resolved.viewer, lines };
@@ -74514,11 +74590,11 @@ var ARGS5 = {
74514
74590
  };
74515
74591
  registerSchema({ command: "canvas.viewer-check", description: DESCRIPTION2, args: ARGS5 });
74516
74592
  async function creativeDirOf(arg, workspaceRoot) {
74517
- const direct = path28.resolve(arg);
74593
+ const direct = path29.resolve(arg);
74518
74594
  try {
74519
- return (await stat4(direct)).isDirectory() ? direct : path28.dirname(direct);
74595
+ return (await stat4(direct)).isDirectory() ? direct : path29.dirname(direct);
74520
74596
  } catch {
74521
- return path28.join(workspaceRoot, "src", "creatives", arg);
74597
+ return path29.join(workspaceRoot, "src", "creatives", arg);
74522
74598
  }
74523
74599
  }
74524
74600
  var viewerCheckCommand = defineCommand138({
@@ -74528,7 +74604,7 @@ var viewerCheckCommand = defineCommand138({
74528
74604
  const cwd = process.cwd();
74529
74605
  const workspaceRoot = findWorkspaceRoot(cwd) ?? cwd;
74530
74606
  const creativeDir = await creativeDirOf(String(args8.slug), workspaceRoot);
74531
- const slug = path28.basename(creativeDir);
74607
+ const slug = path29.basename(creativeDir);
74532
74608
  let creative;
74533
74609
  try {
74534
74610
  creative = await readCreativeForViewerCheck(creativeDir);
@@ -74545,7 +74621,7 @@ var viewerCheckCommand = defineCommand138({
74545
74621
  }
74546
74622
  const resolved = await resolveViewer(creative.sources, liveViewerLookups(workspaceRoot));
74547
74623
  const input = { ...creative.ad, viewer: resolved.viewer };
74548
- const cacheDir = path28.join(workspaceRoot, "canvas", ".cache");
74624
+ const cacheDir = path29.join(workspaceRoot, "canvas", ".cache");
74549
74625
  const spent = [];
74550
74626
  const outcome2 = await checkViewer(input, {
74551
74627
  cacheDir,
@@ -74562,6 +74638,19 @@ var viewerCheckCommand = defineCommand138({
74562
74638
  ...resolved.notes.length > 0 ? { notes: resolved.notes } : {}
74563
74639
  } : {};
74564
74640
  if (outcome2.status === "failed") {
74641
+ const turnId = optionalTurnId();
74642
+ const ledger = await readTurnRenders(cacheDir);
74643
+ if (viewerChecksSpent(ledger, turnId, slug)) {
74644
+ writeJson({
74645
+ ok: true,
74646
+ data: { checked: true, understood: false, viewer: who, missed: outcome2.verdict.missed, ...detail },
74647
+ hints: [
74648
+ `This ad failed the viewer check twice in this turn: render it as written (baker canvas run --slug ${slug}) and tell the person what ${who} missed: ${outcome2.verdict.missed.map(missLine).join(" ")}`
74649
+ ]
74650
+ });
74651
+ return;
74652
+ }
74653
+ if (turnId) await writeTurnRenders(cacheDir, recordViewerFail(ledger, turnId, slug));
74565
74654
  const failure = viewerCheckFailure(outcome2, slug);
74566
74655
  writeJson({ ...failure, error: { ...failure.error, ...detail } });
74567
74656
  process.exit(2);
@@ -74604,9 +74693,9 @@ var GLANCE_REQUIRED = {
74604
74693
  var GLANCE_WIDTH = 400;
74605
74694
  var GlanceAnswerSchema = ViewerAnswerSchema.extend({
74606
74695
  /** What kind of product or service they take it for (D4). */
74607
- category: z65.object({ answer: z65.string().default(""), clarity: z65.enum(["clear", "guess", "unclear"]).default("unclear") }).default({ answer: "", clarity: "unclear" }),
74608
- stopped: z65.string().default(""),
74609
- me: z65.enum(["yes", "no", "unsure"]).default("unsure")
74696
+ category: z66.object({ answer: z66.string().default(""), clarity: z66.enum(["clear", "guess", "unclear"]).default("unclear") }).default({ answer: "", clarity: "unclear" }),
74697
+ stopped: z66.string().default(""),
74698
+ me: z66.enum(["yes", "no", "unsure"]).default("unsure")
74610
74699
  });
74611
74700
  function wherePhrase2(placements) {
74612
74701
  const fullScreen = placements.length === 0 || placements.some((p) => /automatic|stor|reel/i.test(p));
@@ -74776,7 +74865,7 @@ function runCostHint(stats, kind) {
74776
74865
  async function textOf(ref2) {
74777
74866
  const r = ref2;
74778
74867
  try {
74779
- if (typeof r?.path === "string") return await readFile23(r.path, "utf8");
74868
+ if (typeof r?.path === "string") return await readFile24(r.path, "utf8");
74780
74869
  if (typeof r?.url === "string") {
74781
74870
  const res = await fetch(r.url);
74782
74871
  return res.ok ? await res.text() : null;
@@ -74843,7 +74932,7 @@ async function reviewStaticRun(opts) {
74843
74932
  let native;
74844
74933
  let palette = [];
74845
74934
  try {
74846
- const spec = readAdSpec(JSON.parse(await readFile23(targets.promptPath, "utf8")));
74935
+ const spec = readAdSpec(JSON.parse(await readFile24(targets.promptPath, "utf8")));
74847
74936
  copy = spec.copy.map((l) => l.text);
74848
74937
  lines = spec.copy.map((l) => ({ text: l.text, lettering: l.lettering, ...l.role ? { role: l.role } : {} }));
74849
74938
  if (spec.native) {
@@ -74877,8 +74966,10 @@ async function reviewStaticRun(opts) {
74877
74966
  () => null
74878
74967
  );
74879
74968
  const comparing = opts.previous ? nearIdenticalFindings(targets.images, opts.previous).catch(() => []) : Promise.resolve([]);
74969
+ const untouched = opts.settled ? await samePictureSizes(targets.images, opts.settled.images) : [];
74970
+ const changed = targets.images.filter((i) => !untouched.includes(i.format));
74880
74971
  const review = await reviewRenderedStatic({
74881
- images: targets.images,
74972
+ images: changed.length > 0 ? changed : targets.images,
74882
74973
  copy,
74883
74974
  lines,
74884
74975
  palette,
@@ -74901,7 +74992,7 @@ async function reviewStaticRun(opts) {
74901
74992
  const glance = review === null ? null : glanced;
74902
74993
  const unchanged = review === null ? [] : compared;
74903
74994
  const fresh = review === null ? null : [...review, ...glance ?? [], ...unchanged];
74904
- const reviewed = fresh === null || !opts.settled ? fresh : carryUnchanged(fresh, opts.settled.review, await samePictureSizes(targets.images, opts.settled.images));
74995
+ const reviewed = fresh === null || !opts.settled ? fresh : carryUnchanged(fresh, opts.settled.review, untouched);
74905
74996
  const verdict2 = staticVerdict(reviewed);
74906
74997
  const hints2 = verdictHints(verdict2);
74907
74998
  const sizeHints = verdict2 === "needs_fixes" ? sizeFixHints(opts.parsed, reviewed) : [];
@@ -75024,12 +75115,12 @@ function variantHints(judge, prefilter) {
75024
75115
  }
75025
75116
 
75026
75117
  // src/commands/canvas/static-spec-repair.ts
75027
- import { readFile as readFile24, writeFile as writeFile14 } from "fs/promises";
75118
+ import { readFile as readFile25, writeFile as writeFile15 } from "fs/promises";
75028
75119
  import sharp3 from "sharp";
75029
- import { z as z66 } from "zod";
75030
- var Answer = z66.object({
75031
- changed: z66.string().min(1),
75032
- set: z66.array(z66.object({ path: z66.string().min(1), value: z66.unknown() }))
75120
+ import { z as z67 } from "zod";
75121
+ var Answer = z67.object({
75122
+ changed: z67.string().min(1),
75123
+ set: z67.array(z67.object({ path: z67.string().min(1), value: z67.unknown() }))
75033
75124
  });
75034
75125
  function withValueAt(root, path68, value2) {
75035
75126
  const [key, ...rest] = path68;
@@ -75083,7 +75174,7 @@ function buildSpecRepairPrompt(spec, findings, schema) {
75083
75174
  var REPAIR_TIMEOUT_MS = 15e4;
75084
75175
  var SHOWN_WIDTH = 768;
75085
75176
  async function repairSpec(opts) {
75086
- const text3 = await readFile24(opts.promptPath, "utf8").catch(() => null);
75177
+ const text3 = await readFile25(opts.promptPath, "utf8").catch(() => null);
75087
75178
  if (text3 === null) return null;
75088
75179
  let before;
75089
75180
  try {
@@ -75095,57 +75186,11 @@ async function repairSpec(opts) {
75095
75186
  const prompt = buildSpecRepairPrompt(text3, opts.findings, adSpecJsonSchema());
75096
75187
  const repair = acceptRepair(before, await askAboutImages(RENDER_REVIEW_MODEL, shown ? [shown] : [], prompt, opts.onSpend, REPAIR_TIMEOUT_MS));
75097
75188
  if (!repair) return null;
75098
- await writeFile14(opts.promptPath, `${JSON.stringify(repair.spec, null, 2)}
75189
+ await writeFile15(opts.promptPath, `${JSON.stringify(repair.spec, null, 2)}
75099
75190
  `, "utf8");
75100
75191
  return repair.changed;
75101
75192
  }
75102
75193
 
75103
- // src/commands/canvas/turn-renders.ts
75104
- import { mkdir as mkdir9, readFile as readFile25, writeFile as writeFile15 } from "fs/promises";
75105
- import path29 from "path";
75106
- import { z as z67 } from "zod";
75107
- var RUNS_PER_TURN = 2;
75108
- var LedgerSchema = z67.object({
75109
- turnId: z67.string(),
75110
- creatives: z67.record(z67.string(), z67.object({ runs: z67.number(), verdict: z67.string().optional() }))
75111
- });
75112
- function turnRenderRefusal(ledger, turnId, slug) {
75113
- const entry = turnId !== void 0 && ledger?.turnId === turnId ? ledger.creatives[slug] : void 0;
75114
- if (!entry) return null;
75115
- if (entry.verdict === "ready" || entry.verdict === "ready_with_notes") {
75116
- return {
75117
- code: "CONFLICT",
75118
- message: `This ad is already ready in this turn (${entry.verdict}): its notes are not defects.`,
75119
- fix: "Show it now. A change the person asks for comes with their next message."
75120
- };
75121
- }
75122
- if (entry.runs >= RUNS_PER_TURN) {
75123
- return {
75124
- code: "CONFLICT",
75125
- message: `This ad was rendered twice in this turn, its automatic corrections included, and is still not ready.`,
75126
- fix: "Show the best version and say in one plain sentence what is left, in the person's words; they decide what changes next."
75127
- };
75128
- }
75129
- return null;
75130
- }
75131
- function recordTurnRender(ledger, turnId, slug, verdict2) {
75132
- const creatives = ledger?.turnId === turnId ? ledger.creatives : {};
75133
- return { turnId, creatives: { ...creatives, [slug]: { runs: (creatives[slug]?.runs ?? 0) + 1, verdict: verdict2 } } };
75134
- }
75135
- var ledgerPath = (cacheDir) => path29.join(cacheDir, "turn-renders.json");
75136
- async function readTurnRenders(cacheDir) {
75137
- try {
75138
- const parsed = LedgerSchema.safeParse(JSON.parse(await readFile25(ledgerPath(cacheDir), "utf8")));
75139
- return parsed.success ? parsed.data : void 0;
75140
- } catch {
75141
- return void 0;
75142
- }
75143
- }
75144
- async function writeTurnRenders(cacheDir, ledger) {
75145
- await mkdir9(cacheDir, { recursive: true });
75146
- await writeFile15(ledgerPath(cacheDir), JSON.stringify(ledger), "utf8");
75147
- }
75148
-
75149
75194
  // src/commands/canvas/run.ts
75150
75195
  var OPERATOR = "Operator only, agents never need it.";
75151
75196
  var runCommand = defineCommand139({
@@ -75412,6 +75457,12 @@ ${describeRewrites(prepared.rewrites)}
75412
75457
  change: instruction.replace(/^Change only this: /, ""),
75413
75458
  ...opts.relayAlso ? { also: opts.relayAlso } : {}
75414
75459
  });
75460
+ const kept = delivered.filter((d) => opts.keepSizes?.includes(d.format));
75461
+ if (kept.length > 0) {
75462
+ parsed = withDeliveredSizes(parsed, Object.fromEntries(kept.map((d) => [d.format, d.path])));
75463
+ process.stdout.write(`[fix] keeping ${kept.map((d) => d.format).join(", ")} as it was: nothing blocking on it
75464
+ `);
75465
+ }
75415
75466
  if (!editInstruction) process.stdout.write(`[fix] correcting the version of run ${from}: ${instruction}
75416
75467
  `);
75417
75468
  }
@@ -75565,6 +75616,7 @@ ${describeRewrites(prepared.rewrites)}
75565
75616
  resumed: resumed && sameSource === true,
75566
75617
  asked: opts.skipViewerCheck === true
75567
75618
  }),
75619
+ spent: viewerChecksSpent(await readTurnRenders(turnCache), turnId, creativeSlug),
75568
75620
  lookups: liveViewerLookups(path30.dirname(canvasRoot)),
75569
75621
  model: VIEWER_CHECK_MODEL,
75570
75622
  ask: gatewayViewerModel(VIEWER_CHECK_MODEL, (usage) => viewerUsage.push(usage))
@@ -75573,6 +75625,9 @@ ${describeRewrites(prepared.rewrites)}
75573
75625
  `);
75574
75626
  await holdUnbilledSpend(cacheDir, viewerUsage);
75575
75627
  if (viewerCheck?.failure) {
75628
+ if (creativeSlug && turnId && viewerCheck.failure.error.code === "viewer_check_failed") {
75629
+ await writeTurnRenders(turnCache, recordViewerFail(await readTurnRenders(turnCache), turnId, creativeSlug));
75630
+ }
75576
75631
  process.stderr.write(`${JSON.stringify(viewerCheck.failure, null, 2)}
75577
75632
  `);
75578
75633
  process.exit(2);
@@ -75830,6 +75885,7 @@ ${describeRewrites(prepared.rewrites)}
75830
75885
  ...step.kind === "fix" ? {
75831
75886
  fix: step.instruction,
75832
75887
  ...step.relay ? { relayAlso: step.relay } : {},
75888
+ ...step.keep ? { keepSizes: step.keep } : {},
75833
75889
  ...step.extend ? { extendSizes: step.extend } : {}
75834
75890
  } : step.kind === "extend" ? { extendSizes: step.sizes, extendOnly: true } : step.kind === "relayout" ? {
75835
75891
  ...step.extend ? { extendSizes: step.extend } : {},
@@ -80998,7 +81054,7 @@ function ideasHints(report) {
80998
81054
  if (next.length === 1) hints2.push(`Build: \`baker creatives make ${next[0]}\`.`);
80999
81055
  if (next.length > 1)
81000
81056
  hints2.push(
81001
- `Build ${next.join(", ")} at once: one \`creative-producer\` per idea, all spawned in one message, each given its idea id. Never build them one after another: each render takes about five minutes.`
81057
+ `Build ${next.join(", ")} at once: one \`creative-producer\` per idea, all spawned in one message in the background, each given its idea id, and handle each as it returns. Never build them one after another: each render takes minutes.`
81002
81058
  );
81003
81059
  return hints2;
81004
81060
  }
@@ -81335,7 +81391,7 @@ function briefHints(parts) {
81335
81391
  }
81336
81392
  if (parts.customers.thin) {
81337
81393
  hints2.push(
81338
- `Only ${parts.customers.observed} customer quotes on file: run \`baker research customers\` before writing ideas, so they stand on what customers said.`
81394
+ `Only ${parts.customers.observed} customer quotes on file: run \`baker research customers\` now, before writing ideas, so they stand on what customers said. It gathers reviews, Reddit and the web and writes the quotes with their sources in about a minute; never a subagent or web searches of your own for this.`
81339
81395
  );
81340
81396
  }
81341
81397
  if (parts.bottleneck && isAvailable(parts.bottleneck) && parts.bottleneck.verdict === "after_click") {