@koda-sl/baker-cli 0.159.0-dev.a0159ea6f → 0.160.0-dev.fa00ad797

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4844,7 +4844,10 @@ var callAdSchema = z13.object({
4844
4844
  var appAdSchema = z13.object({
4845
4845
  format: z13.literal("app"),
4846
4846
  headlines: z13.array(z13.object({ text: z13.string().min(1).max(30) })).min(1),
4847
- descriptions: z13.array(z13.object({ text: z13.string().min(1).max(90) })).min(1)
4847
+ descriptions: z13.array(z13.object({ text: z13.string().min(1).max(90) })).min(1),
4848
+ // An App campaign ad carries its images on its own content (`AppAdInfo.images`), not as
4849
+ // campaign-level asset links — same shape as a responsive display ad's marketing images.
4850
+ images: z13.array(refSchema).optional()
4848
4851
  });
4849
4852
  var videoAdSchema = z13.object({
4850
4853
  format: z13.literal("video"),
@@ -6183,6 +6186,443 @@ import { defineCommand as defineCommand22 } from "citty";
6183
6186
  // src/commands/ads/google/write-shared.ts
6184
6187
  import { readFileSync as readFileSync2 } from "fs";
6185
6188
 
6189
+ // src/engine/copy-tells/patterns.ts
6190
+ var PHRASE_TELLS = {
6191
+ // §33 — the fake-candid pause before an ordinary claim. The single loudest
6192
+ // tell in AI-written Meta primary text. Anchored to a sentence start by the
6193
+ // detector, so "take a look at" and "honestly, I agree" mid-sentence are safe.
6194
+ "rhetorical-opener": [
6195
+ "honestly\\?",
6196
+ "honestly,",
6197
+ "here's the thing",
6198
+ "here is the thing",
6199
+ "the thing is",
6200
+ "let's be honest",
6201
+ "let's be real",
6202
+ "real talk",
6203
+ "look,",
6204
+ "listen,",
6205
+ "la verdad es que",
6206
+ "sinceramente,",
6207
+ "seamos honestos",
6208
+ "seamos sinceros",
6209
+ "mira,",
6210
+ "escucha,",
6211
+ "te lo digo claro"
6212
+ ],
6213
+ // §9 — negative parallelism. Our landing doctrine already names this in prose;
6214
+ // this is the detector that was missing.
6215
+ "negation-pivot": [
6216
+ "it's not just",
6217
+ "it is not just",
6218
+ "it's not merely",
6219
+ "not only .{3,40} but also",
6220
+ "less about .{3,40} more about",
6221
+ "isn't about .{3,40} it's about",
6222
+ "no se trata solo de",
6223
+ "no se trata \xFAnicamente de",
6224
+ "no solo .{3,40} sino",
6225
+ "no es solo .{3,40} es",
6226
+ "m\xE1s que .{3,40}, es"
6227
+ ],
6228
+ // §3 — present participle hung off a comma to fake depth. Detector requires
6229
+ // the preceding comma, so a sentence-initial participle is not a tell.
6230
+ "participle-tail": [
6231
+ "ensuring",
6232
+ "highlighting",
6233
+ "underscoring",
6234
+ "emphasizing",
6235
+ "showcasing",
6236
+ "fostering",
6237
+ "empowering",
6238
+ "reflecting",
6239
+ "symbolizing",
6240
+ "contributing to",
6241
+ "encompassing",
6242
+ "cultivating",
6243
+ "permitiendo",
6244
+ "garantizando",
6245
+ "impulsando",
6246
+ "reflejando",
6247
+ "consolidando",
6248
+ "potenciando"
6249
+ ],
6250
+ // §8 — elaborate constructions standing in for "is" and "has".
6251
+ "copula-avoidance": [
6252
+ "serves as",
6253
+ "stands as",
6254
+ "boasts",
6255
+ "represents a",
6256
+ "marks a",
6257
+ "se erige como",
6258
+ "se posiciona como",
6259
+ "cuenta con",
6260
+ "se consolida como",
6261
+ "constituye un"
6262
+ ],
6263
+ // §27 — pretending to cut through noise before restating an ordinary point.
6264
+ "authority-trope": [
6265
+ "the real question is",
6266
+ "at its core",
6267
+ "what really matters",
6268
+ "the heart of the matter",
6269
+ "the deeper issue",
6270
+ "in reality,",
6271
+ "la verdadera pregunta",
6272
+ "en el fondo,",
6273
+ "lo que realmente importa",
6274
+ "en esencia,"
6275
+ ],
6276
+ // §28 — announcing the writing instead of doing it.
6277
+ signposting: [
6278
+ "let's dive in",
6279
+ "let's dive into",
6280
+ "let's explore",
6281
+ "let's break (?:this|it) down",
6282
+ "here's what you need to know",
6283
+ "here is what you need to know",
6284
+ "now let's look at",
6285
+ "without further ado",
6286
+ "vamos a ver",
6287
+ "vamos a analizar",
6288
+ "profundicemos",
6289
+ "esto es lo que necesitas saber",
6290
+ "te lo explicamos paso a paso"
6291
+ ],
6292
+ // §20 — chatbot correspondence pasted in as content. Always a defect: it means
6293
+ // the agent shipped its own conversational frame into client-facing copy.
6294
+ //
6295
+ // This is the only tell the landing gate fires at ONE occurrence, so every
6296
+ // entry has to be unambiguous. The delivery openers are therefore bound to the
6297
+ // noun the assistant hands over ("here is a summary", "aquí tienes el
6298
+ // resumen"): a bare "here is a…" / "aquí tienes…" is ordinary marketing copy.
6299
+ "chat-artifact": [
6300
+ "i hope this helps",
6301
+ "let me know if",
6302
+ "would you like me to",
6303
+ "want me to",
6304
+ "here (?:is|are) (?:an? |the )?(?:overview|summary|breakdown|rundown|outline|draft|revised version)",
6305
+ "here's (?:an? |the )?(?:overview|summary|breakdown|rundown|outline|draft|revised version)",
6306
+ "certainly!",
6307
+ "great question",
6308
+ "you're absolutely right",
6309
+ "espero que (?:te sirva|esto ayude)",
6310
+ "av\xEDsame si",
6311
+ "\xBFquieres que",
6312
+ "aqu\xED tienes (?:un |una |el |la |los |las )?(?:resumen|listado|desglose|borrador|esquema|propuesta|versi\xF3n)",
6313
+ "por supuesto!"
6314
+ ],
6315
+ // §5 — opinions attributed to nobody in particular.
6316
+ "vague-attribution": [
6317
+ "experts agree",
6318
+ "experts say",
6319
+ "experts argue",
6320
+ "studies show",
6321
+ "research shows",
6322
+ "industry reports",
6323
+ "observers have",
6324
+ "some critics",
6325
+ "it is believed that",
6326
+ "los expertos coinciden",
6327
+ "los expertos afirman",
6328
+ "los estudios demuestran",
6329
+ "seg\xFAn varios informes",
6330
+ "se cree que"
6331
+ ],
6332
+ // §25 — vague upbeat endings that assert nothing.
6333
+ "hollow-conclusion": [
6334
+ "the future looks bright",
6335
+ "exciting times",
6336
+ "a step in the right direction",
6337
+ "the possibilities are endless",
6338
+ "the sky's the limit",
6339
+ "el futuro es prometedor",
6340
+ "las posibilidades son infinitas",
6341
+ "un paso en la direcci\xF3n correcta",
6342
+ "el cielo es el l\xEDmite"
6343
+ ],
6344
+ // §23 — words that carry no load.
6345
+ "filler-phrase": [
6346
+ "in order to",
6347
+ "due to the fact that",
6348
+ "at this point in time",
6349
+ "has the ability to",
6350
+ "have the ability to",
6351
+ "it is important to note",
6352
+ "it is worth noting",
6353
+ "con el fin de",
6354
+ "debido al hecho de que",
6355
+ "en este momento del tiempo",
6356
+ "tiene la capacidad de",
6357
+ "cabe destacar",
6358
+ "cabe se\xF1alar",
6359
+ "es importante se\xF1alar"
6360
+ ],
6361
+ // §32 — ordinary claims dressed as reusable aphorisms.
6362
+ "aphorism-formula": [
6363
+ "the language of",
6364
+ "the currency of",
6365
+ "the architecture of",
6366
+ "the backbone of",
6367
+ "el lenguaje de",
6368
+ "la moneda de",
6369
+ "la columna vertebral de"
6370
+ ]
6371
+ };
6372
+ var SENTENCE_INITIAL_TELLS = /* @__PURE__ */ new Set(["rhetorical-opener"]);
6373
+ var COMMA_ANCHORED_TELLS = /* @__PURE__ */ new Set(["participle-tail"]);
6374
+ var STACCATO_MAX_WORDS = 4;
6375
+ var STACCATO_RUN = 3;
6376
+ var SYNONYM_OVERLAP = 0.6;
6377
+ var HEADLINE_SET_FLOOR = 3;
6378
+ var CYCLED_VERBS = [
6379
+ ["boost", "increase", "grow", "raise", "lift", "improve", "enhance", "maximize", "drive"],
6380
+ ["cut", "reduce", "lower", "slash", "shrink", "minimize", "decrease"],
6381
+ ["get", "obtain", "receive", "secure", "unlock", "claim"],
6382
+ ["fast", "quick", "rapid", "swift", "speedy", "instant"],
6383
+ ["aumenta", "incrementa", "mejora", "impulsa", "potencia", "maximiza", "multiplica"],
6384
+ ["reduce", "recorta", "baja", "disminuye", "minimiza"],
6385
+ ["consigue", "obt\xE9n", "logra", "alcanza", "desbloquea"],
6386
+ ["r\xE1pido", "veloz", "inmediato", "al instante"]
6387
+ ];
6388
+
6389
+ // src/engine/copy-tells/detect.ts
6390
+ var SAMPLE_MAX = 60;
6391
+ function sample(text) {
6392
+ const clean = text.replace(/\s+/g, " ").trim();
6393
+ return clean.length <= SAMPLE_MAX ? clean : `${clean.slice(0, SAMPLE_MAX)}\u2026`;
6394
+ }
6395
+ var DELIBERATE_REGEX = /\.\{\d+,\d+\}|\(\?:[^()]*\)|\\?\?/g;
6396
+ function escapeLiteral(text) {
6397
+ return text.replace(/[.*+^$?()|[\]{}\\]/g, (ch) => `\\${ch}`);
6398
+ }
6399
+ function toPattern(phrase) {
6400
+ let out = "";
6401
+ let last = 0;
6402
+ for (const m of phrase.matchAll(DELIBERATE_REGEX)) {
6403
+ const start = m.index ?? 0;
6404
+ out += escapeLiteral(phrase.slice(last, start)) + m[0];
6405
+ last = start + m[0].length;
6406
+ }
6407
+ return out + escapeLiteral(phrase.slice(last));
6408
+ }
6409
+ var WORD_START = "(?<![\\p{L}\\p{N}])";
6410
+ function matcherFor(id, phrase) {
6411
+ const body = toPattern(phrase);
6412
+ if (SENTENCE_INITIAL_TELLS.has(id)) return new RegExp(`(?:^|[.!?\xBF\xA1]\\s+|\\n)\\s*${body}`, "giu");
6413
+ if (COMMA_ANCHORED_TELLS.has(id)) return new RegExp(`,\\s+${body}\\b`, "giu");
6414
+ return new RegExp(`${WORD_START}${body}`, "giu");
6415
+ }
6416
+ var FALSE_RANGE_PATTERNS = [
6417
+ /\bfrom\s+([^,.;]{3,30}?)\s+to\s+([^,.;]{3,30}?)(?=[,.;]|$)/gi,
6418
+ /\bdesde\s+([^,.;]{3,30}?)\s+hasta\s+([^,.;]{3,30}?)(?=[,.;]|$)/gi
6419
+ ];
6420
+ function countFalseRanges(text) {
6421
+ let count = 0;
6422
+ let first = "";
6423
+ for (const re of FALSE_RANGE_PATTERNS) {
6424
+ for (const m of text.matchAll(re)) {
6425
+ const left = m[1] ?? "";
6426
+ const right = m[2] ?? "";
6427
+ if (/\d/.test(left) || /\d/.test(right)) continue;
6428
+ count++;
6429
+ if (!first) first = m[0] ?? "";
6430
+ }
6431
+ }
6432
+ return { count, first };
6433
+ }
6434
+ function countStaccato(text) {
6435
+ const sentences = text.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
6436
+ let run = 0;
6437
+ let count = 0;
6438
+ let first = "";
6439
+ let runStart = 0;
6440
+ for (const [i, s] of sentences.entries()) {
6441
+ const words2 = s.split(/\s+/).filter(Boolean).length;
6442
+ if (words2 > 0 && words2 <= STACCATO_MAX_WORDS) {
6443
+ if (run === 0) runStart = i;
6444
+ run++;
6445
+ if (run === STACCATO_RUN) {
6446
+ count++;
6447
+ if (!first) first = sentences.slice(runStart, i + 1).join(" ");
6448
+ }
6449
+ } else {
6450
+ run = 0;
6451
+ }
6452
+ }
6453
+ return { count, first };
6454
+ }
6455
+ function countTitleCaseHeadings(text) {
6456
+ let count = 0;
6457
+ let first = "";
6458
+ for (const m of text.matchAll(/^\s{0,3}#{1,6}\s+(.+)$/gm)) {
6459
+ const heading = (m[1] ?? "").trim();
6460
+ const words2 = heading.split(/\s+/).filter((w) => new RegExp("\\p{L}", "u").test(w));
6461
+ if (words2.length < 4) continue;
6462
+ const capitalized = words2.filter((w) => new RegExp("^\\p{Lu}", "u").test(w)).length;
6463
+ if (capitalized / words2.length < 0.8) continue;
6464
+ count++;
6465
+ if (!first) first = heading;
6466
+ }
6467
+ return { count, first };
6468
+ }
6469
+ function countInlineHeaderList(text) {
6470
+ const matches = [...text.matchAll(/^\s*(?:[-*+]|\d+\.)\s+\*\*[^*\n]{2,40}:?\*\*:?\s/gm)];
6471
+ if (matches.length < 3) return { count: 0, first: "" };
6472
+ return { count: matches.length, first: (matches[0]?.[0] ?? "").trim() };
6473
+ }
6474
+ var EMOJI = /[\u{1F300}-\u{1FAFF}]|[\u{2600}-\u{27BF}]|[\u{1F000}-\u{1F2FF}]|\u{FE0F}/gu;
6475
+ var CURLY = /[‘’“”]/g;
6476
+ function countPhraseTell(text, id, phrases) {
6477
+ let count = 0;
6478
+ let first = "";
6479
+ for (const phrase of phrases) {
6480
+ for (const m of text.matchAll(matcherFor(id, phrase))) {
6481
+ count++;
6482
+ if (!first) first = m[0] ?? "";
6483
+ }
6484
+ }
6485
+ return { count, first };
6486
+ }
6487
+ function countPattern(text, re) {
6488
+ const matches = text.match(re) ?? [];
6489
+ return { count: matches.length, first: matches.join("") };
6490
+ }
6491
+ function detectCopyTells(text) {
6492
+ if (!text.trim()) return [];
6493
+ const results = [
6494
+ ...Object.entries(PHRASE_TELLS).map(([id, phrases]) => [
6495
+ id,
6496
+ countPhraseTell(text, id, phrases)
6497
+ ]),
6498
+ ["false-range", countFalseRanges(text)],
6499
+ ["staccato-drama", countStaccato(text)],
6500
+ ["title-case-heading", countTitleCaseHeadings(text)],
6501
+ ["inline-header-list", countInlineHeaderList(text)],
6502
+ ["curly-quote", countPattern(text, CURLY)],
6503
+ ["emoji", countPattern(text, EMOJI)]
6504
+ ];
6505
+ return results.filter(([, result]) => result.count > 0).map(([id, result]) => ({ id, count: result.count, sample: sample(result.first) }));
6506
+ }
6507
+ function words(headline) {
6508
+ return headline.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean);
6509
+ }
6510
+ function canonical(word) {
6511
+ for (const [i, group] of CYCLED_VERBS.entries()) if (group.includes(word)) return `group:${i}`;
6512
+ return word;
6513
+ }
6514
+ function overlap(a, b) {
6515
+ if (a.size === 0 || b.size === 0) return 0;
6516
+ let shared = 0;
6517
+ for (const w of a) if (b.has(w)) shared++;
6518
+ return shared / Math.max(a.size, b.size);
6519
+ }
6520
+ function detectHeadlineSetTells(headlines) {
6521
+ const usable = headlines.map((h) => h.trim()).filter(Boolean);
6522
+ if (usable.length < HEADLINE_SET_FLOOR) return [];
6523
+ const sets = usable.map((h) => new Set(words(h).map(canonical)));
6524
+ const cycled = [];
6525
+ for (let i = 0; i < sets.length; i++) {
6526
+ for (let j = i + 1; j < sets.length; j++) {
6527
+ if (overlap(sets[i] ?? /* @__PURE__ */ new Set(), sets[j] ?? /* @__PURE__ */ new Set()) >= SYNONYM_OVERLAP) {
6528
+ cycled.push(`"${usable[i]}" / "${usable[j]}"`);
6529
+ }
6530
+ }
6531
+ }
6532
+ if (cycled.length === 0) return [];
6533
+ return [{ id: "synonym-cycling", count: cycled.length, sample: sample(cycled[0] ?? "") }];
6534
+ }
6535
+
6536
+ // src/commands/ads/copy-hints.ts
6537
+ var NOT_APPLICABLE_TO_ADS = /* @__PURE__ */ new Set([
6538
+ "title-case-heading",
6539
+ "staccato-drama",
6540
+ "emoji",
6541
+ "curly-quote",
6542
+ "inline-header-list"
6543
+ ]);
6544
+ var COPY_KEYS = /* @__PURE__ */ new Set([
6545
+ "headline",
6546
+ "headlines",
6547
+ "longheadline",
6548
+ "longheadlines",
6549
+ "shortheadline",
6550
+ "shortheadlines",
6551
+ "description",
6552
+ "descriptions",
6553
+ "primarytext",
6554
+ "primarytexts",
6555
+ "message",
6556
+ "messages",
6557
+ "commentary",
6558
+ "body",
6559
+ "bodies",
6560
+ "text",
6561
+ "calltoaction",
6562
+ "linkdescription",
6563
+ "sitelinktext",
6564
+ "callout",
6565
+ "callouts",
6566
+ "introtext"
6567
+ ]);
6568
+ var HEADLINE_SET_KEYS = /* @__PURE__ */ new Set(["headlines", "longheadlines", "shortheadlines"]);
6569
+ var MAX_HINTS = 5;
6570
+ var SYNONYM_PAIR_FLOOR = 2;
6571
+ function asStrings(value) {
6572
+ const one = (v) => {
6573
+ const text = typeof v === "string" ? v : v !== null && typeof v === "object" ? v.text : "";
6574
+ return typeof text === "string" && text.trim() ? text : void 0;
6575
+ };
6576
+ const values = Array.isArray(value) ? value : [value];
6577
+ return values.map(one).filter((v) => v !== void 0);
6578
+ }
6579
+ function collectCopy(value, depth = 0) {
6580
+ const texts = [];
6581
+ const headlineSets = [];
6582
+ if (depth > 4 || value === null || typeof value !== "object") return { texts, headlineSets };
6583
+ if (Array.isArray(value)) {
6584
+ for (const item of value) {
6585
+ const nested = collectCopy(item, depth + 1);
6586
+ texts.push(...nested.texts);
6587
+ headlineSets.push(...nested.headlineSets);
6588
+ }
6589
+ return { texts, headlineSets };
6590
+ }
6591
+ for (const [key, raw] of Object.entries(value)) {
6592
+ const normalized = key.toLowerCase().replace(/[_-]/g, "");
6593
+ const strings = COPY_KEYS.has(normalized) ? asStrings(raw) : [];
6594
+ if (strings.length > 0) {
6595
+ texts.push(...strings);
6596
+ if (HEADLINE_SET_KEYS.has(normalized) && strings.length > 1) headlineSets.push(strings);
6597
+ continue;
6598
+ }
6599
+ const nested = collectCopy(raw, depth + 1);
6600
+ texts.push(...nested.texts);
6601
+ headlineSets.push(...nested.headlineSets);
6602
+ }
6603
+ return { texts, headlineSets };
6604
+ }
6605
+ function hintFor(tell) {
6606
+ if (tell.id === "synonym-cycling") {
6607
+ return `AI COPY TELL \u2014 ${tell.count} headline pairs are the same claim reworded (${tell.sample}). A cycled set gives the auction nothing to choose between: make each headline a different claim, not a different verb.`;
6608
+ }
6609
+ return `AI COPY TELL (${tell.id}) \u2014 "${tell.sample}". This construction reads as machine-written; rewrite it as a plain, specific claim before publishing.`;
6610
+ }
6611
+ function adCopyHints(payload) {
6612
+ const { texts, headlineSets } = collectCopy(payload);
6613
+ if (texts.length === 0) return [];
6614
+ const tells = detectCopyTells(texts.join("\n")).filter((t) => !NOT_APPLICABLE_TO_ADS.has(t.id));
6615
+ for (const set of headlineSets) {
6616
+ for (const tell of detectHeadlineSetTells(set)) {
6617
+ if (tell.count >= SYNONYM_PAIR_FLOOR) tells.push(tell);
6618
+ }
6619
+ }
6620
+ tells.sort(
6621
+ (a, b) => (b.id === "synonym-cycling" ? 1 : 0) - (a.id === "synonym-cycling" ? 1 : 0) || b.count - a.count
6622
+ );
6623
+ return tells.slice(0, MAX_HINTS).map(hintFor);
6624
+ }
6625
+
6186
6626
  // src/commands/ads/google/draft-status.ts
6187
6627
  var OPERATION_LABELS = {
6188
6628
  create: "Creating",
@@ -6454,7 +6894,7 @@ async function stageGoogleOp(raw, hints) {
6454
6894
  try {
6455
6895
  const chatId = requireChatId();
6456
6896
  const response = await apiPost("/api/ads/google/draft/stage", { chatId, op: preflight.data });
6457
- const allHints = [...noopHints(response.data), ...hints ?? []];
6897
+ const allHints = [...noopHints(response.data), ...hints ?? [], ...adCopyHints(preflight.data)];
6458
6898
  writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
6459
6899
  } catch (err) {
6460
6900
  handleGoogleError(err);
@@ -6496,15 +6936,16 @@ async function stageGoogleOps(rawOps, hints) {
6496
6936
  ...skipped.length > 0 ? [
6497
6937
  `${skipped.length} of ${ops.length} op(s) NOT STAGED \u2014 already in the requested state: ${skipped.map((s) => s.reason).join("; ")}`
6498
6938
  ] : [],
6499
- ...hints ?? []
6939
+ ...hints ?? [],
6940
+ ...adCopyHints(ops)
6500
6941
  ];
6501
6942
  writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
6502
6943
  } catch (err) {
6503
6944
  handleGoogleError(err);
6504
6945
  }
6505
6946
  }
6506
- async function stageUpdate(kind, customerId, target, payload) {
6507
- await stageGoogleOp({ kind, customerId, target, payload });
6947
+ async function stageUpdate(kind, customerId, target, payload, hints) {
6948
+ await stageGoogleOp({ kind, customerId, target, payload }, hints);
6508
6949
  }
6509
6950
  async function stageTarget(kind, customerId, target) {
6510
6951
  await stageGoogleOp({ kind, customerId, target });
@@ -8381,6 +8822,25 @@ function demandGenContentFromFlags(args, base) {
8381
8822
  }
8382
8823
  return content;
8383
8824
  }
8825
+ function appContentFromFlags(args, base) {
8826
+ const content = {
8827
+ format: "app",
8828
+ ...base !== null && typeof base === "object" && !Array.isArray(base) ? base : {}
8829
+ };
8830
+ const headlines = listFlag(args.headlines);
8831
+ if (headlines) {
8832
+ content.headlines = headlines.map((text) => ({ text }));
8833
+ }
8834
+ const descriptions = listFlag(args.descriptions);
8835
+ if (descriptions) {
8836
+ content.descriptions = descriptions.map((text) => ({ text }));
8837
+ }
8838
+ const images = listFlag(args["image-assets"]);
8839
+ if (images) {
8840
+ content.images = images;
8841
+ }
8842
+ return content;
8843
+ }
8384
8844
  function adContentFromFlags(args, format, fileContent) {
8385
8845
  if (format === "responsiveSearch") {
8386
8846
  return fileContent ?? rsaContentFromFlags(args);
@@ -8394,8 +8854,48 @@ function adContentFromFlags(args, format, fileContent) {
8394
8854
  if (format === "demandGen") {
8395
8855
  return demandGenContentFromFlags(args, fileContent);
8396
8856
  }
8857
+ if (format === "app") {
8858
+ return appContentFromFlags(args, fileContent);
8859
+ }
8397
8860
  return fileContent ?? failWriteValidation(`--format ${format} needs --file with the ad content`);
8398
8861
  }
8862
+ var adContentArgs = {
8863
+ format: {
8864
+ type: "string",
8865
+ description: "responsiveSearch (default) | responsiveDisplay | call | app | video | demandGen"
8866
+ },
8867
+ headlines: { type: "string", description: "Comma-separated headlines (RSA/RDA/demandGen/app)" },
8868
+ descriptions: { type: "string", description: "Comma-separated descriptions (RSA/RDA/demandGen/app)" },
8869
+ path1: { type: "string" },
8870
+ path2: { type: "string" },
8871
+ "long-headline": { type: "string", description: "Long headline (responsiveDisplay)" },
8872
+ "business-name": { type: "string", description: "Business name (responsiveDisplay/demandGen)" },
8873
+ "marketing-images": {
8874
+ type: "string",
8875
+ description: "Comma-separated marketing image asset refs (responsiveDisplay)"
8876
+ },
8877
+ "square-marketing-images": {
8878
+ type: "string",
8879
+ description: "Comma-separated square marketing image asset refs (responsiveDisplay)"
8880
+ },
8881
+ "logo-images": { type: "string", description: "Comma-separated logo image asset refs (responsiveDisplay)" },
8882
+ "video-assets": {
8883
+ type: "string",
8884
+ description: `Comma-separated video asset refs (video format) \u2014 stage the YouTube video as an asset first (assets create --file '{"type":"youtubeVideo",\u2026}')`
8885
+ },
8886
+ "image-assets": { type: "string", description: "Comma-separated image asset refs (demandGen/app)" },
8887
+ "square-image-assets": { type: "string", description: "Comma-separated square image asset refs (demandGen)" },
8888
+ "logo-image-assets": { type: "string", description: "Comma-separated logo image asset refs (demandGen)" },
8889
+ "final-url": { type: "string", description: "Comma-separated final URLs" }
8890
+ };
8891
+ function assertNoContentFlags(args) {
8892
+ const passed = Object.keys(adContentArgs).filter((flag) => flag !== "format" && args[flag] !== void 0);
8893
+ if (passed.length > 0) {
8894
+ failWriteValidation(
8895
+ `${passed.map((flag) => `--${flag}`).join(", ")} only build an ad's content alongside --format (responsiveSearch | responsiveDisplay | call | app | video | demandGen) \u2014 add it, or pass --file with the complete content`
8896
+ );
8897
+ }
8898
+ }
8399
8899
  var adsCommand = defineCommand30({
8400
8900
  meta: { name: "ads", description: "Stage ad create/update/pause/resume/remove" },
8401
8901
  subCommands: {
@@ -8408,33 +8908,7 @@ var adsCommand = defineCommand30({
8408
8908
  ...customerIdArg,
8409
8909
  ...fileArg,
8410
8910
  "ad-group-ref": { type: "string", description: "Ad group ref" },
8411
- format: {
8412
- type: "string",
8413
- description: "responsiveSearch (default) | responsiveDisplay | call | app | video | demandGen"
8414
- },
8415
- headlines: { type: "string", description: "Comma-separated headlines (RSA/RDA/demandGen)" },
8416
- descriptions: { type: "string", description: "Comma-separated descriptions (RSA/RDA/demandGen)" },
8417
- path1: { type: "string" },
8418
- path2: { type: "string" },
8419
- "long-headline": { type: "string", description: "Long headline (responsiveDisplay)" },
8420
- "business-name": { type: "string", description: "Business name (responsiveDisplay/demandGen)" },
8421
- "marketing-images": {
8422
- type: "string",
8423
- description: "Comma-separated marketing image asset refs (responsiveDisplay)"
8424
- },
8425
- "square-marketing-images": {
8426
- type: "string",
8427
- description: "Comma-separated square marketing image asset refs (responsiveDisplay)"
8428
- },
8429
- "logo-images": { type: "string", description: "Comma-separated logo image asset refs (responsiveDisplay)" },
8430
- "video-assets": {
8431
- type: "string",
8432
- description: `Comma-separated video asset refs (video format) \u2014 stage the YouTube video as an asset first (assets create --file '{"type":"youtubeVideo",\u2026}')`
8433
- },
8434
- "image-assets": { type: "string", description: "Comma-separated image asset refs (demandGen)" },
8435
- "square-image-assets": { type: "string", description: "Comma-separated square image asset refs (demandGen)" },
8436
- "logo-image-assets": { type: "string", description: "Comma-separated logo image asset refs (demandGen)" },
8437
- "final-url": { type: "string", description: "Comma-separated final URLs" },
8911
+ ...adContentArgs,
8438
8912
  status: { type: "string" }
8439
8913
  },
8440
8914
  run: async ({ args }) => {
@@ -8450,17 +8924,27 @@ var adsCommand = defineCommand30({
8450
8924
  }
8451
8925
  }),
8452
8926
  update: defineCommand30({
8453
- meta: { name: "update", description: "Stage an ad update" },
8454
- args: { ...customerIdArg, ...fileArg, status: { type: "string" } },
8927
+ meta: {
8928
+ name: "update",
8929
+ description: "Stage an ad update. Any content change needs --format plus EVERY required field of that format (or --file with the complete content) \u2014 a field you send replaces its current value wholesale, so a list you pass must already include the entries you want to keep. Optional asset lists you omit entirely are left untouched on the ad. Status-only changes need neither --format nor content flags."
8930
+ },
8931
+ args: { ...customerIdArg, ...fileArg, ...adContentArgs, status: { type: "string" } },
8455
8932
  run: async ({ args }) => {
8456
8933
  const customerId = requireCustomerId(args);
8457
8934
  const file = loadJsonFileArg(args.file);
8458
- await stageUpdate(
8459
- "google.ad.update",
8460
- customerId,
8461
- requireTarget(args, "ad"),
8462
- mergePayload(file, { status: args.status })
8463
- );
8935
+ const format = args.format;
8936
+ if (!format) {
8937
+ assertNoContentFlags(args);
8938
+ }
8939
+ const payload = mergePayload(file, {
8940
+ status: args.status,
8941
+ ...format ? { content: adContentFromFlags(args, format, file.content) } : {}
8942
+ });
8943
+ await stageUpdate("google.ad.update", customerId, requireTarget(args, "ad"), payload, [
8944
+ ...payload.content !== void 0 ? [
8945
+ "Each content field you sent replaces its current value wholesale at publish \u2014 re-check that every headline, description, final URL and asset you want to KEEP is in this payload, not just the ones you changed."
8946
+ ] : []
8947
+ ]);
8464
8948
  }
8465
8949
  }),
8466
8950
  pause: statusCommand2("google.ad.pause", "ad"),
@@ -10539,7 +11023,7 @@ async function stageOp(raw, hints) {
10539
11023
  chatId,
10540
11024
  op: preflight.data
10541
11025
  });
10542
- const allHints = [...batchNudge(response.data), ...hints ?? []];
11026
+ const allHints = [...batchNudge(response.data), ...hints ?? [], ...adCopyHints(preflight.data)];
10543
11027
  writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
10544
11028
  } catch (err) {
10545
11029
  handleLinkedinError(err);
@@ -10566,7 +11050,8 @@ async function stageLinkedinOps(rawOps, hints) {
10566
11050
  try {
10567
11051
  const chatId = requireChatId();
10568
11052
  const response = await apiPost("/api/ads/linkedin/draft/stage-batch", { chatId, ops });
10569
- writeJsonEnvelope(hints && hints.length > 0 ? { ...response, hints } : response);
11053
+ const allHints = [...hints ?? [], ...adCopyHints(ops)];
11054
+ writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
10570
11055
  } catch (err) {
10571
11056
  handleLinkedinError(err);
10572
11057
  }
@@ -13652,7 +14137,8 @@ async function stageOp2(raw) {
13652
14137
  try {
13653
14138
  const chatId = requireChatId();
13654
14139
  const response = await apiPost("/api/ads/meta/draft/stage", { chatId, op: preflight.data });
13655
- writeJsonEnvelope(response);
14140
+ const copyHints = adCopyHints(preflight.data);
14141
+ writeJsonEnvelope(copyHints.length > 0 ? { ...response, hints: copyHints } : response);
13656
14142
  } catch (err) {
13657
14143
  handleMetaError(err);
13658
14144
  }
@@ -17511,10 +17997,10 @@ function estSpeechS(text) {
17511
17997
  var OBSERVED_WPS_MIN = 1;
17512
17998
  var OBSERVED_WPS_MAX = 6;
17513
17999
  function estSpeechWindowS(text, startS, endS) {
17514
- const words = wordCount(text);
18000
+ const words2 = wordCount(text);
17515
18001
  const window = (endS ?? 0) - (startS ?? 0);
17516
- if (words > 0 && window > 0.3) {
17517
- const wps = words / window;
18002
+ if (words2 > 0 && window > 0.3) {
18003
+ const wps = words2 / window;
17518
18004
  if (wps >= OBSERVED_WPS_MIN && wps <= OBSERVED_WPS_MAX) return window;
17519
18005
  }
17520
18006
  return estSpeechS(text);
@@ -18366,10 +18852,10 @@ function scrubFloatSentences(text, floatDescs) {
18366
18852
  if (floatDescs.length === 0 || !text) return text;
18367
18853
  const tokenSets = floatDescs.map((d) => new Set(floatTokens(d)));
18368
18854
  const kept = text.split(/(?<=[.!?])\s+/).filter((sentence) => {
18369
- const words = new Set(floatTokens(sentence));
18855
+ const words2 = new Set(floatTokens(sentence));
18370
18856
  return !tokenSets.some((ts) => {
18371
18857
  let hits = 0;
18372
- for (const w of words) if (ts.has(w)) hits++;
18858
+ for (const w of words2) if (ts.has(w)) hits++;
18373
18859
  return hits >= 2;
18374
18860
  });
18375
18861
  }).join(" ").trim();
@@ -19099,10 +19585,10 @@ function isOnCameraSpeaker(speaker, casts, cameraOn) {
19099
19585
  if (NARRATOR_SPEAKERS.has(speaker.toLowerCase())) return false;
19100
19586
  return casts.has(speaker);
19101
19587
  }
19102
- function makePresenterPresent(slots, canonical, opts = {}) {
19588
+ function makePresenterPresent(slots, canonical2, opts = {}) {
19103
19589
  const personSlots = slots.filter((s) => s.type.toLowerCase() === "person");
19104
19590
  const bySpeaker = /* @__PURE__ */ new Map();
19105
- for (const slot of personSlots) if (slot.castId) bySpeaker.set(canonical(slot.castId), slot.presence);
19591
+ for (const slot of personSlots) if (slot.castId) bySpeaker.set(canonical2(slot.castId), slot.presence);
19106
19592
  const solePerson = !opts.strict && personSlots.length === 1 ? personSlots[0].presence : null;
19107
19593
  return (speaker, sceneIndex) => {
19108
19594
  const presence = bySpeaker.get(speaker) ?? solePerson;
@@ -19134,17 +19620,17 @@ function joinDialogueTexts(texts) {
19134
19620
  }
19135
19621
  const prevWords = prev.split(/\s+/).filter(Boolean);
19136
19622
  const nextWords = text.split(/\s+/).filter(Boolean);
19137
- let overlap = 0;
19623
+ let overlap2 = 0;
19138
19624
  const maxK = Math.min(JOIN_DEDUP_MAX_WORDS, prevWords.length, nextWords.length);
19139
19625
  for (let k = maxK; k >= 1; k--) {
19140
19626
  const tail = prevWords.slice(-k).map(joinKey).join(" ");
19141
19627
  const head = nextWords.slice(0, k).map(joinKey).join(" ");
19142
19628
  if (tail && tail === head) {
19143
- overlap = k;
19629
+ overlap2 = k;
19144
19630
  break;
19145
19631
  }
19146
19632
  }
19147
- const rest = nextWords.slice(overlap).join(" ");
19633
+ const rest = nextWords.slice(overlap2).join(" ");
19148
19634
  if (rest) out.push(rest);
19149
19635
  }
19150
19636
  return out.join(" ");
@@ -19162,13 +19648,13 @@ function collapseVoiceover(blueprint) {
19162
19648
  const presenter = [...presenters][0];
19163
19649
  return (speaker) => NARRATOR_SPEAKERS.has(speaker.toLowerCase()) ? presenter : speaker;
19164
19650
  }
19165
- function multiSpeakerScenes(blueprint, casts, cameraOn, canonical, presentStrict) {
19651
+ function multiSpeakerScenes(blueprint, casts, cameraOn, canonical2, presentStrict) {
19166
19652
  const multiSpeaker = /* @__PURE__ */ new Set();
19167
19653
  blueprint.scenes.forEach((scene, i) => {
19168
19654
  const onCamAll = new Set(
19169
19655
  (scene.dialogue ?? []).map((l) => l.speaker ?? "voiceover").filter((sp) => isOnCameraSpeaker(sp, casts, cameraOn))
19170
19656
  );
19171
- const onCamPresent = [...onCamAll].filter((sp) => presentStrict(canonical(sp), i));
19657
+ const onCamPresent = [...onCamAll].filter((sp) => presentStrict(canonical2(sp), i));
19172
19658
  const effective = onCamPresent.length > 0 ? new Set(onCamPresent) : onCamAll;
19173
19659
  if (effective.size >= 2) multiSpeaker.add(i);
19174
19660
  });
@@ -19199,14 +19685,14 @@ function dialogueLines(blueprint, ctx) {
19199
19685
  });
19200
19686
  }).sort((a, b) => a.start - b.start);
19201
19687
  }
19202
- function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, presentStrict) {
19688
+ function buildPhrases(blueprint, canonical2, compositeScenes, presenterPresent, presentStrict) {
19203
19689
  const casts = castIdSet(blueprint);
19204
19690
  const cameraOn = onCameraDialogue(blueprint);
19205
- const multiSpeaker = multiSpeakerScenes(blueprint, casts, cameraOn, canonical, presentStrict);
19691
+ const multiSpeaker = multiSpeakerScenes(blueprint, casts, cameraOn, canonical2, presentStrict);
19206
19692
  const lines = dialogueLines(blueprint, {
19207
19693
  compositeScenes,
19208
19694
  multiSpeaker,
19209
- canonical,
19695
+ canonical: canonical2,
19210
19696
  casts,
19211
19697
  cameraOn,
19212
19698
  presenterPresent
@@ -19255,12 +19741,12 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
19255
19741
  flush();
19256
19742
  return phrases;
19257
19743
  }
19258
- function makeVoiceFactory(blueprint, canonical, nodes) {
19744
+ function makeVoiceFactory(blueprint, canonical2, nodes) {
19259
19745
  const bySpeaker = /* @__PURE__ */ new Map();
19260
19746
  const describe = (speaker) => {
19261
19747
  for (const scene of blueprint.scenes)
19262
19748
  for (const line of scene.dialogue ?? [])
19263
- if (canonical(line.speaker ?? "voiceover") === speaker && line.voice_description) return line.voice_description;
19749
+ if (canonical2(line.speaker ?? "voiceover") === speaker && line.voice_description) return line.voice_description;
19264
19750
  const cast = blueprint.global?.cast?.find((c) => c.id === speaker);
19265
19751
  return cast?.description ?? blueprint.global?.voiceover?.voice_description ?? `${speaker} voice`;
19266
19752
  };
@@ -19447,15 +19933,15 @@ function emitPhraseTts(phrase, voiceNode, idx, used, clock, nodes, out, language
19447
19933
  speaker: phrase.speaker
19448
19934
  });
19449
19935
  }
19450
- function emitCompositeInTimeline(composite, scene, i, isLast, env, canonical, ensureVoiceNode, usedVoIds, nodes, out) {
19936
+ function emitCompositeInTimeline(composite, scene, i, isLast, env, canonical2, ensureVoiceNode, usedVoIds, nodes, out) {
19451
19937
  const present2 = slotsForScene(env.slots, i);
19452
19938
  const onCam = (scene.dialogue ?? []).filter(
19453
19939
  (l) => Boolean(l.line?.trim()) && isOnCameraSpeaker(l.speaker ?? "voiceover", env.casts, env.cameraOn)
19454
19940
  );
19455
- const distinctSpeakers = new Set(onCam.map((l) => canonical(l.speaker ?? "voiceover")));
19941
+ const distinctSpeakers = new Set(onCam.map((l) => canonical2(l.speaker ?? "voiceover")));
19456
19942
  let nativeTurn;
19457
19943
  if (onCam.length > 0 && distinctSpeakers.size === 1) {
19458
- const speaker = canonical(onCam[0]?.speaker ?? "voiceover");
19944
+ const speaker = canonical2(onCam[0]?.speaker ?? "voiceover");
19459
19945
  const voiceNode = ensureVoiceNode(speaker);
19460
19946
  const start = onCam[0]?.start_s ?? scene.start_s ?? 0;
19461
19947
  const end = onCam[onCam.length - 1]?.end_s ?? scene.end_s ?? start;
@@ -19500,7 +19986,7 @@ function emitCompositeInTimeline(composite, scene, i, isLast, env, canonical, en
19500
19986
  onCam,
19501
19987
  scene,
19502
19988
  i,
19503
- canonical,
19989
+ canonical2,
19504
19990
  ensureVoiceNode,
19505
19991
  usedVoIds,
19506
19992
  env.clock,
@@ -19510,10 +19996,10 @@ function emitCompositeInTimeline(composite, scene, i, isLast, env, canonical, en
19510
19996
  );
19511
19997
  }
19512
19998
  }
19513
- function emitCompositeMultiSpeakerVoice(onCam, scene, i, canonical, ensureVoiceNode, usedVoIds, clock, nodes, out, languageCode) {
19999
+ function emitCompositeMultiSpeakerVoice(onCam, scene, i, canonical2, ensureVoiceNode, usedVoIds, clock, nodes, out, languageCode) {
19514
20000
  const bySpeaker = /* @__PURE__ */ new Map();
19515
20001
  for (const l of onCam) {
19516
- const speaker = canonical(l.speaker ?? "voiceover");
20002
+ const speaker = canonical2(l.speaker ?? "voiceover");
19517
20003
  const text = l.line.trim();
19518
20004
  const start = l.start_s ?? scene.start_s ?? 0;
19519
20005
  const end = l.end_s ?? start + estSpeechS(text);
@@ -19666,8 +20152,8 @@ function buildTimeline(blueprint, slots, opts, nodes) {
19666
20152
  if (layeredComposition(s, uiRouted.has(i))) compositeScenes.add(i);
19667
20153
  });
19668
20154
  }
19669
- const canonical = collapseVoiceover(blueprint);
19670
- const ensureVoiceNode = makeVoiceFactory(blueprint, canonical, nodes);
20155
+ const canonical2 = collapseVoiceover(blueprint);
20156
+ const ensureVoiceNode = makeVoiceFactory(blueprint, canonical2, nodes);
19671
20157
  const aspect = resolveAspect(blueprint.source?.aspect_ratio, opts.aspect, genAspectsFor(opts.videoModel));
19672
20158
  const env = {
19673
20159
  blueprint,
@@ -19694,9 +20180,9 @@ function buildTimeline(blueprint, slots, opts, nodes) {
19694
20180
  nativeSegments: [],
19695
20181
  sceneSlice: /* @__PURE__ */ new Map()
19696
20182
  };
19697
- const presenterPresent = makePresenterPresent(slots, canonical);
19698
- const presentStrict = makePresenterPresent(slots, canonical, { strict: true });
19699
- const phrases = buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, presentStrict);
20183
+ const presenterPresent = makePresenterPresent(slots, canonical2);
20184
+ const presentStrict = makePresenterPresent(slots, canonical2, { strict: true });
20185
+ const phrases = buildPhrases(blueprint, canonical2, compositeScenes, presenterPresent, presentStrict);
19700
20186
  const usedVoIds = /* @__PURE__ */ new Set();
19701
20187
  const claimed = /* @__PURE__ */ new Set();
19702
20188
  phrases.forEach((phrase, k) => {
@@ -19720,7 +20206,7 @@ function buildTimeline(blueprint, slots, opts, nodes) {
19720
20206
  i,
19721
20207
  i === lastIndex,
19722
20208
  env,
19723
- canonical,
20209
+ canonical2,
19724
20210
  ensureVoiceNode,
19725
20211
  usedVoIds,
19726
20212
  nodes,
@@ -28525,6 +29011,24 @@ var BUZZWORDS = [
28525
29011
  "drive results",
28526
29012
  "harness the power"
28527
29013
  ];
29014
+ var COPY_TELL_GATES = {
29015
+ "chat-artifact": { minCount: 1, severity: "warn" },
29016
+ "rhetorical-opener": { minCount: 2, severity: "warn" },
29017
+ "negation-pivot": { minCount: 2, severity: "warn" },
29018
+ "vague-attribution": { minCount: 2, severity: "warn" },
29019
+ "participle-tail": { minCount: 2, severity: "advisory" },
29020
+ "copula-avoidance": { minCount: 2, severity: "advisory" },
29021
+ "authority-trope": { minCount: 2, severity: "advisory" },
29022
+ signposting: { minCount: 2, severity: "advisory" },
29023
+ "hollow-conclusion": { minCount: 2, severity: "advisory" },
29024
+ "filler-phrase": { minCount: 2, severity: "advisory" },
29025
+ "aphorism-formula": { minCount: 2, severity: "advisory" },
29026
+ "false-range": { minCount: 2, severity: "advisory" },
29027
+ "staccato-drama": { minCount: 2, severity: "advisory" },
29028
+ "title-case-heading": { minCount: 2, severity: "advisory" },
29029
+ "inline-header-list": { minCount: 3, severity: "advisory" },
29030
+ emoji: { minCount: 3, severity: "advisory" }
29031
+ };
28528
29032
  var AUTONOMY_CLAIMS = [
28529
29033
  "fully autonomous",
28530
29034
  "completely autonomous",
@@ -28682,6 +29186,86 @@ var RULE_META = {
28682
29186
  severity: "warn",
28683
29187
  note: "Dense em-dashes are an AI cadence tell (and brand copy bans them). Recast with commas, colons, or full stops."
28684
29188
  },
29189
+ "chat-artifact": {
29190
+ family: "copy",
29191
+ severity: "warn",
29192
+ note: "Chatbot framing ('Here is an overview\u2026', 'av\xEDsame si') leaked into the page. Delete it and state the claim directly."
29193
+ },
29194
+ "rhetorical-opener": {
29195
+ family: "copy",
29196
+ severity: "warn",
29197
+ note: "Fake-candid openers ('Honestly?', 'La verdad es que') manufacture intimacy before an ordinary claim. Just say the thing."
29198
+ },
29199
+ "negation-pivot": {
29200
+ family: "copy",
29201
+ severity: "warn",
29202
+ note: "'It's not just X, it's Y' / 'no se trata solo de' is now a louder AI tell than any single word. Cut the setup, keep the claim."
29203
+ },
29204
+ "vague-attribution": {
29205
+ family: "copy",
29206
+ severity: "warn",
29207
+ note: "'Experts agree' / 'los estudios demuestran' attributes to nobody. Name the source and the year, or drop the claim."
29208
+ },
29209
+ "participle-tail": {
29210
+ family: "copy",
29211
+ severity: "advisory",
29212
+ note: "Participles hung off a comma (', ensuring\u2026', ', permitiendo\u2026') fake depth. End the sentence, or make it a real clause."
29213
+ },
29214
+ "copula-avoidance": {
29215
+ family: "copy",
29216
+ severity: "advisory",
29217
+ note: "'serves as' / 'cuenta con' dodges plain 'is' and 'has'. Use the simple verb \u2014 it reads as confidence, not flatness."
29218
+ },
29219
+ "authority-trope": {
29220
+ family: "copy",
29221
+ severity: "advisory",
29222
+ note: "'At its core' / 'en el fondo' pretends to reach a deeper truth before restating an ordinary point. Make the point once."
29223
+ },
29224
+ signposting: {
29225
+ family: "copy",
29226
+ severity: "advisory",
29227
+ note: "Announcing the writing ('Let's dive in', 'vamos a ver') instead of doing it. Delete the announcement, keep the content."
29228
+ },
29229
+ "hollow-conclusion": {
29230
+ family: "copy",
29231
+ severity: "advisory",
29232
+ note: "'The future looks bright' / 'las posibilidades son infinitas' asserts nothing. End on a concrete next step or a number."
29233
+ },
29234
+ "filler-phrase": {
29235
+ family: "copy",
29236
+ severity: "advisory",
29237
+ note: "Filler ('in order to', 'cabe destacar que') costs words and says nothing. Cut to the verb."
29238
+ },
29239
+ "aphorism-formula": {
29240
+ family: "copy",
29241
+ severity: "advisory",
29242
+ note: "'X is the currency of Y' sounds profound without adding precision. Replace it with the concrete claim it gestures at."
29243
+ },
29244
+ "false-range": {
29245
+ family: "copy",
29246
+ severity: "advisory",
29247
+ note: "'From onboarding to retention' pairs ends that aren't on a scale. List what you actually cover."
29248
+ },
29249
+ "staccato-drama": {
29250
+ family: "copy",
29251
+ severity: "advisory",
29252
+ note: "A run of very short sentences manufactures drama. One clipped line lands; three in a row read as engineered."
29253
+ },
29254
+ "title-case-heading": {
29255
+ family: "copy",
29256
+ severity: "advisory",
29257
+ note: "Title Case headings are an AI tell \u2014 and in Spanish the convention doesn't exist at all. Use sentence case."
29258
+ },
29259
+ "inline-header-list": {
29260
+ family: "copy",
29261
+ severity: "advisory",
29262
+ note: "'**Speed:** it is fast' bullets are AI list shape. Write the sentence, or let the bullet carry the content directly."
29263
+ },
29264
+ emoji: {
29265
+ family: "copy",
29266
+ severity: "advisory",
29267
+ note: "Emoji-decorated copy is a chatbot habit. Let the words and the layout carry the emphasis."
29268
+ },
28685
29269
  "dark-glow": {
28686
29270
  family: "borders_depth",
28687
29271
  severity: "warn",
@@ -28884,6 +29468,14 @@ function includesPhraseAtWordStart(haystack, phrase) {
28884
29468
  function stripHtmlToText(html) {
28885
29469
  return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
28886
29470
  }
29471
+ function stripHtmlPreservingHeadings(html) {
29472
+ return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(
29473
+ /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi,
29474
+ (_all, _lvl, inner) => `
29475
+ ## ${String(inner).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()}
29476
+ `
29477
+ ).replace(/<\/(?:p|div|section|li|tr|br)>|<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n");
29478
+ }
28887
29479
  function blankAstroFrontmatter(text) {
28888
29480
  if (!text.startsWith("---")) return text;
28889
29481
  const lines = text.split("\n");
@@ -29068,6 +29660,23 @@ var ANALYZERS = [
29068
29660
  return [{ id: "aphoristic-cadence", snippet: `${count} aphoristic constructions: "${firstSample}"`, file }];
29069
29661
  }
29070
29662
  },
29663
+ // Shared AI copy tells (engine/copy-tells) — the Wikipedia "Signs of AI
29664
+ // writing" set, English and Spanish. Page-scope: every gate here is a count
29665
+ // threshold, and a landing is always split across small components, so
29666
+ // measuring per file would dilute a page-wide habit into nothing.
29667
+ {
29668
+ scope: "page",
29669
+ run: (text, file) => {
29670
+ const body = stripHtmlPreservingHeadings(text);
29671
+ const out = [];
29672
+ for (const tell of detectCopyTells(body)) {
29673
+ const gate = COPY_TELL_GATES[tell.id];
29674
+ if (!gate || tell.count < gate.minCount) continue;
29675
+ out.push({ id: tell.id, snippet: `${tell.count}\xD7 \u2014 "${tell.sample}"`, file });
29676
+ }
29677
+ return out;
29678
+ }
29679
+ },
29071
29680
  // Gradient text in authored CSS. A rule block spans lines, so this scans a
29072
29681
  // ±6-line window around each `background-clip: text` instead of one line
29073
29682
  // (the Tailwind class form stays a line matcher above).