@koda-sl/baker-cli 0.201.0 → 0.203.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 CHANGED
@@ -2505,6 +2505,16 @@ List testimonials with optional filters.
2505
2505
  baker testimonials list --source google --sentiment positive --limit 20
2506
2506
  ```
2507
2507
 
2508
+ When `list` or `search` returns nothing, the envelope carries `meta.empty` explaining why, checked against the company's connected review sources rather than inferred from the empty result:
2509
+
2510
+ | `meta.empty.reason` | Meaning | What to do |
2511
+ | --- | --- | --- |
2512
+ | `filtered_out` | Filters were applied, so the corpus itself was not examined | Drop every filter and re-run once |
2513
+ | `no_corpus_connected` | The company has no review source connected | Terminal — source proof another way; no rewording will help |
2514
+ | `corpus_empty` | A source is connected but holds no testimonials yet | Ingestion may still be running; do not report the client as having no reviews |
2515
+
2516
+ If the source check itself fails, `meta.empty` is omitted entirely and the hint says so — an unverified absence is never reported as one.
2517
+
2508
2518
  ### `baker testimonials tags`
2509
2519
 
2510
2520
  List the available testimonial tag names — built-in defaults plus the company's custom tags. Use it before filtering with `--tags`. Defaults to a markdown list (`--output json` for the `{ ok, data }` envelope).
@@ -2797,8 +2807,8 @@ baker actions create --name "Build hero" --priority high --tags landing,creative
2797
2807
  # surface — doing the work is the default, an action is the exception. One advisory hint per call: the
2798
2808
  # scheduled redirect wins when both match.
2799
2809
  # `draftCreateCount` is how many creates this chat has staged (counted after this op, so it drops again
2800
- # after `draft remove`). At 3 or more, create emits a fan-out hint: re-read the draft and merge actions
2801
- # that touch the same system into one.
2810
+ # after `draft remove`). At exactly 2, create emits a one-line merge nudge; at 3 or more it emits the
2811
+ # fuller fan-out hint: re-read the draft and merge actions that touch the same system into one.
2802
2812
  # If no --tags are passed, create emits a MISSING --tags hint — the action is invisible to the backlog's tag filter.
2803
2813
  # If no --priority is passed, create emits a MISSING --priority hint — the action ranks as normal/medium in the do-first order.
2804
2814
  baker actions update <action-id> --name "Better name" --tags google-ads,strategy # --tags REPLACES the set; '' clears
package/dist/cli.js CHANGED
@@ -587,6 +587,9 @@ function parseTagList(value) {
587
587
  const tags = value.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
588
588
  return [...new Set(tags)];
589
589
  }
590
+ function fold(text2) {
591
+ return text2.normalize("NFD").replace(/[̀-ͯ]/g, "");
592
+ }
590
593
  var SCHEDULE_SIGNALS = [
591
594
  /\brecurr(?:ing|ence)\b/i,
592
595
  /\bcadence\b/i,
@@ -626,28 +629,95 @@ function parsePriority(value, { allowClear }) {
626
629
  );
627
630
  }
628
631
  function looksScheduled(name, description) {
629
- const haystack = `${name}
630
- ${description}`;
631
- return SCHEDULE_SIGNALS.some((re) => re.test(haystack));
632
- }
632
+ const haystack = fold(`${name}
633
+ ${description}`);
634
+ return SCHEDULE_SIGNALS.some((re2) => re2.test(haystack));
635
+ }
636
+ var CAMPAIGN_ENTITY = String.raw`(?:campaigns?|campanas?|ad\s*sets?|adsets?|ad\s*groups?|adgroups?|ads?|anuncios?|conjuntos?\s+de\s+anuncios|grupos?\s+de\s+anuncios)`;
637
+ var LIFECYCLE_VERB = "(?:pause|unpause|enable|disable|launch|restructure|duplicate|clone|rebuild|prune|reweight|split|consolidate|pausar|pausa|activar|podar|poda|reponderar|duplicar|reestructurar|consolidar)";
638
+ var BUDGET_VERB = "(?:cap|raise|lower|increase|decrease|adjust|change|set|update|reallocate|shift|limitar|subir|bajar|ajustar|aplicar|reasignar)";
639
+ var re = (...parts) => new RegExp(parts.join(""), "i");
633
640
  var SURFACE_RULES = [
641
+ {
642
+ surface: "ask-user",
643
+ signals: [
644
+ /\bproduction\s+(?:domain|url|site|hostname)\b/i,
645
+ /\b(?:confirm|check|verify|confirmar|verificar)\s+(?:with|w\/|con)\s+(?:the\s+|el\s+|la\s+)?(?:client|user|customer|team|cliente|usuario|equipo)\b/i,
646
+ /\bmissing\s+(?:the\s+)?(?:value|values|id|ids|url|budget|domain|credentials?|account\s+id|measurement\s+id)\b/i,
647
+ /\b(?:ask|get)\s+(?:the\s+)?(?:client|user)\s+for\b/i,
648
+ /\b(?:pedir|solicitar)\s+(?:al\s+)?cliente\b/i
649
+ ],
650
+ hint: "This is a missing input, not blocked work. Ask for it with AskUserQuestion (one question), then do the work in the same turn \u2014 filing the Task AND asking the question is the same request twice."
651
+ },
634
652
  {
635
653
  surface: "analysis",
636
654
  nameOnly: true,
637
655
  signals: [
638
- /^\s*(?:re-?)?(?:investigate|analyse|analyze|audit|review|document|assess|evaluate|research|map|inventory|examine|diagnose)\b/i
656
+ /^\s*(?:re-?)?(?:investigat|analy[sz]|audit|review|document|assess|evaluat|research|map|inventor|examin|diagnos|verif|validat|benchmark)\w*\b/i,
657
+ /^\s*(?:auditar|auditoria|investigar|investigacion|revisar|revision|analizar|analisis|documentar|evaluar|evaluacion|diagnosticar|diagnostico|mapear|inventariar|verificar|verificacion|comprobar|estudiar|estudio)\b/i
639
658
  ],
640
659
  hint: "This names the analysis itself \u2014 investigating, auditing and documenting are work you do now, not work you file. Run it in this chat and deliver the finding; a Task is for a fix something genuinely blocks (bar: `__tooling__/docs/tools/baker/actions.md`)."
641
660
  },
642
661
  {
643
- surface: "ask-user",
662
+ surface: "ads-write",
663
+ // "Approve X" is a human decision, not a staged write — and the budget
664
+ // signals are broad enough to swallow it otherwise. Name-anchored so a
665
+ // description recalling an earlier approval doesn't veto real work.
666
+ exceptName: [/^\s*(?:approve|approval|sign[-\s]?off|aprobar|aprobacion)\b/i],
667
+ // Bare `keywords` is the work when it's the subject of the Task, but plain
668
+ // context when a description happens to discuss search terms.
669
+ nameOnlySignals: [/\b(?:keywords?|palabras\s+clave)\b/i],
644
670
  signals: [
645
- /\bproduction\s+(?:domain|url|site|hostname)\b/i,
646
- /\b(?:confirm|check|verify)\s+(?:with|w\/)\s+(?:the\s+)?(?:client|user|customer|team)\b/i,
647
- /\bmissing\s+(?:the\s+)?(?:value|values|id|ids|url|budget|domain|credentials?|account\s+id|measurement\s+id)\b/i,
648
- /\b(?:ask|get)\s+(?:the\s+)?(?:client|user)\s+for\b/i
671
+ // Targeting.
672
+ /\b(?:geo|location|geographic|geografic\w*)[\s-]*(?:targeting|target|segmentacion)\b/i,
673
+ /\bsegmentacion\s+(?:geografica|por\s+ubicacion)\b/i,
674
+ /\bpresence[-\s_]?(?:only|or[-\s_]?interest)\b/i,
675
+ /\b(?:ad\s*schedul\w*|dayparting|calendario\s+de\s+anuncios)\b/i,
676
+ // Extensions / assets.
677
+ /\b(?:callouts?|sitelinks?|site\s*links?|structured\s+snippets?|price\s+extensions?|lead\s+form\s+extensions?|extensiones|asset\s+groups?)\b/i,
678
+ // Ad copy. Compound forms only — bare `headline` and `copy` belong to
679
+ // `landing`, and an RSA headline has to say so.
680
+ /\b(?:rsas?|responsive\s+search\s+ads?|ad\s+copy|copy\s+de\s+(?:los\s+)?anuncios)\b/i,
681
+ /\b(?:headline|titular|description)\s+(?:pool|set|slots?)\b/i,
682
+ /\bdisplay\s+paths?\b|\brutas?\s+visibles?\b/i,
683
+ // Keywords. `negatives` is bare in English because in this product's
684
+ // vocabulary the noun is always negative keywords; the Spanish side is
685
+ // feminine-only, because "resultados negativos" is a real phrase.
686
+ /\bmatch\s+types?\b|\bconcordancias?\b/i,
687
+ /\bnegatives?\b/i,
688
+ /\b(?:palabras\s+clave\s+)?negativas\b/i,
689
+ /\bkeyword\s+lists?\b|\blistas?\s+de\s+palabras\s+clave\b/i,
690
+ // Audiences. `LAL` is the operators' own shorthand for a lookalike and
691
+ // has no other reading in this corpus.
692
+ /\b(?:in-?market|affinity|audience\s+signals?|senales?\s+de\s+audiencia)\b/i,
693
+ /\b(?:audiencias?|lal)\b/i,
694
+ /\blistas?\s+de\s+(?:exclusion|remarketing)\b|\bexclusion\s+lists?\b/i,
695
+ // URLs.
696
+ /\btracking\s+(?:url\s+)?templates?\b|\bfinal\s+urls?\b|\bfinal\s+url\s+suffix\b/i,
697
+ // Bidding.
698
+ /\bbid\s+(?:adjustments?|modifiers?|strateg\w*)\b|\bajustes?\s+de\s+puja\b/i,
699
+ /\b(?:tcpa|troas|target\s+cpa|target\s+roas)\b/i,
700
+ // Lifecycle. Bare `campaign` is deliberately NOT a signal — it appears
701
+ // everywhere and would strip tag-manager and landing of correct
702
+ // routings, so every campaign-shaped rule needs a verb or a specific
703
+ // setting noun next to it.
704
+ // Bounded, and never across the name/description boundary. `ads` is an
705
+ // ordinary word in this domain, so an unbounded gap lets a verb in the
706
+ // name pair with a passing mention in the description — and since
707
+ // ads-write is evaluated first, that spurious match outranks the surface
708
+ // which should have answered.
709
+ re(String.raw`\b`, LIFECYCLE_VERB, String.raw`\b[^\n]{0,40}\b`, CAMPAIGN_ENTITY, String.raw`\b`),
710
+ // Budget. Two complementary shapes — verb before, quantity after — so a
711
+ // change stated either way lands, guarded by the `exceptName` veto above.
712
+ /\b(?:daily|campaign|lifetime|ad\s*set|minimum|min|competitor)\s+budgets?\b/i,
713
+ /\bpresupuestos?\b/i,
714
+ re(String.raw`\b`, BUDGET_VERB, String.raw`\b[\s\S]{0,24}\bbudgets?\b`),
715
+ /\bbudgets?\b[\s\S]{0,24}\b(?:at|to|cap|down|up|\d|%|€|\$)/i,
716
+ /\bnegative\s+keywords?\b/i,
717
+ /\b(?:lookalike|custom)\s+audience\b/i,
718
+ /\bswap\s+(?:the\s+)?creative\b/i
649
719
  ],
650
- hint: "This is a missing input, not blocked work. Ask for it with AskUserQuestion (one question), then do the work in the same turn \u2014 filing the Task AND asking the question is the same request twice."
720
+ hint: "This is an ad-platform change the write surface covers \u2014 stage it now with `baker ads google|meta|linkedin` and it applies at publish (guides: `__tooling__/docs/tools/baker/ads-google.md`, `__tooling__/docs/tools/baker/ads-meta.md`, `__tooling__/docs/tools/baker/ads-linkedin.md`). Staging is not going live."
651
721
  },
652
722
  {
653
723
  surface: "tag-manager",
@@ -661,7 +731,10 @@ var SURFACE_RULES = [
661
731
  /\bga4\s+(?:config|configuration|event)\b/i,
662
732
  /\bcontainer\s+(?:tag|trigger|variable)s?\b/i,
663
733
  /\b(?:conversion|form|submit|submission|click|purchase|lead|custom)\s+event\b/i,
664
- /\btrack\w*\b[\s\S]*\bevent\b/i
734
+ /\btrack\w*\b[\s\S]*\bevent\b/i,
735
+ /\beventos?\s+de\s+(?:formulario|conversion|clic|compra)\b/i,
736
+ /\bconsentimiento\b/i,
737
+ /\bmedicion\b/i
665
738
  ],
666
739
  except: [/\bserver-?\s?side\b/i, /\bsgtm\b/i, /\bhosting\b/i],
667
740
  hint: "This is a change inside the GTM container \u2014 `baker tag-manager` stages it now and it applies at publish (guide: `__tooling__/docs/tools/baker/tag-manager.md`). Stage it instead of filing it, and if several container fixes really are blocked, they belong in ONE Task, not one per finding."
@@ -674,30 +747,20 @@ var SURFACE_RULES = [
674
747
  /\bclarity\b/i,
675
748
  /\bhotjar\b/i,
676
749
  /\bgtm\s+snippet\b/i,
677
- /\b(?:install|add|remove|swap)\b[\s\S]*\b(?:snippet|script|tag)\b/i
750
+ /\bcapi\b/i,
751
+ /\b(?:install|add|remove|swap|instalar|colocar)\b[\s\S]*\b(?:snippet|script|tag)\b/i
678
752
  ],
753
+ // Two vetoes. A structured snippet is a Google Ads extension, not a script
754
+ // on the page — `ads-write` claims it above, and this stops both rules
755
+ // being able to answer. Server-side delivery is infrastructure nobody here
756
+ // can provision, the same call `tag-manager` already makes for sGTM.
757
+ except: [/\bstructured\s+snippets?\b/i, /\bserver-?\s?side\b/i, /\bhosting\b/i],
679
758
  hint: "This is a tag or script on the site \u2014 it goes through the `request_tag_input` approval form in this chat, which also collects any secret values (guide: `__tooling__/docs/tools/baker/tags.md`). Show the form instead of filing a Task."
680
759
  },
681
- {
682
- surface: "ads-write",
683
- signals: [
684
- /\b(?:pause|unpause|enable|disable|launch|restructure|duplicate|clone|rebuild)\b[\s\S]*\b(?:campaign|ad\s*set|ad\s*group|adgroup)\b/i,
685
- /\b(?:tcpa|troas|target\s+cpa|target\s+roas)\b/i,
686
- /\b(?:daily|campaign|lifetime|ad\s*set)\s+budget\b/i,
687
- /\b(?:raise|lower|increase|decrease|adjust|change|set|update)\s+(?:the\s+)?budget\b/i,
688
- /\bbid\s+strateg/i,
689
- /\bnegative\s+keywords?\b/i,
690
- /\b(?:lookalike|custom)\s+audience\b/i,
691
- /\bswap\s+(?:the\s+)?creative\b/i
692
- ],
693
- hint: "This is an ad-platform change the write surface covers \u2014 stage it now with `baker ads google|meta|linkedin` and it applies at publish (guides: `__tooling__/docs/tools/baker/ads-google.md`, `__tooling__/docs/tools/baker/ads-meta.md`, `__tooling__/docs/tools/baker/ads-linkedin.md`). Staging is not going live."
694
- },
695
760
  {
696
761
  surface: "landing",
697
- signals: [
698
- /\b(?:landing\s+page|hero|headline|above\s+the\s+fold)\b/i,
699
- /\b(?:rewrite|tighten|restyle)\b[\s\S]*\b(?:copy|page|section)\b/i
700
- ],
762
+ nameOnlySignals: [/\b(?:landing\s+pages?|landings?|hero|headline|above\s+the\s+fold|pagina)\b/i],
763
+ signals: [/\b(?:rewrite|tighten|restyle)\b[\s\S]*\b(?:copy|page|section)\b/i],
701
764
  hint: "This is a page change \u2014 build it in this chat with the `/landing` skill. Only file it if something outside the page blocks the work."
702
765
  },
703
766
  {
@@ -706,17 +769,25 @@ var SURFACE_RULES = [
706
769
  hint: "This is form behaviour \u2014 change it in this chat with the `/flow-builder` skill. Only file it if something outside the form blocks the work."
707
770
  }
708
771
  ];
772
+ var FAN_OUT_NUDGE_THRESHOLD = 2;
709
773
  var FAN_OUT_THRESHOLD = 3;
710
774
  var SCHEDULED_HINT = 'This reads as recurring/scheduled work. If it should run on a cadence or a future date, create a Scheduled Action instead \u2014 baker scheduled-actions create --cron "0 9 * * MON" (or --run-at; guide: `__tooling__/docs/tools/baker/scheduled-actions.md`). Do NOT capture "set up a scheduled action" as a Work Action.';
711
775
  function looksExecutable(name, description) {
712
- const full = `${name}
713
- ${description}`;
776
+ const foldedName = fold(name);
777
+ const full = `${foldedName}
778
+ ${fold(description)}`;
714
779
  for (const rule of SURFACE_RULES) {
715
- const haystack = rule.nameOnly ? name : full;
716
- if (rule.except?.some((re) => re.test(full))) {
780
+ const haystack = rule.nameOnly ? foldedName : full;
781
+ if (rule.except?.some((re2) => re2.test(full))) {
717
782
  continue;
718
783
  }
719
- if (rule.signals.some((re) => re.test(haystack))) {
784
+ if (rule.exceptName?.some((re2) => re2.test(foldedName))) {
785
+ continue;
786
+ }
787
+ if (rule.signals.some((re2) => re2.test(haystack))) {
788
+ return { surface: rule.surface, hint: rule.hint };
789
+ }
790
+ if (rule.nameOnlySignals?.some((re2) => re2.test(foldedName))) {
720
791
  return { surface: rule.surface, hint: rule.hint };
721
792
  }
722
793
  }
@@ -741,6 +812,10 @@ function buildCreateHints({
741
812
  hints.push(
742
813
  `${draftCreateCount} Tasks are staged in this chat. Re-read them with \`baker actions draft\`: can any be done now with a surface you already have, and do any two touch the same system (same container, same campaign, same page)? Those belong in ONE Task \u2014 fold them together with \`baker actions update\` and drop the extras with \`baker actions draft remove <tempId>\`.`
743
814
  );
815
+ } else if (draftCreateCount === FAN_OUT_NUDGE_THRESHOLD) {
816
+ hints.push(
817
+ `Second Task staged in this chat. If these two land on the same system (same container, same campaign, same page) they are ONE Task \u2014 fold them with \`baker actions update\` and drop the extra with \`baker actions draft remove ${tempId}\`.`
818
+ );
744
819
  }
745
820
  hints.push(`Link dependencies: baker actions link --blocker <id> --blocked ${tempId}`);
746
821
  if (!description) {
@@ -8357,8 +8432,8 @@ var FALSE_RANGE_PATTERNS = [
8357
8432
  function countFalseRanges(text2) {
8358
8433
  let count = 0;
8359
8434
  let first = "";
8360
- for (const re of FALSE_RANGE_PATTERNS) {
8361
- for (const m of text2.matchAll(re)) {
8435
+ for (const re2 of FALSE_RANGE_PATTERNS) {
8436
+ for (const m of text2.matchAll(re2)) {
8362
8437
  const left = m[1] ?? "";
8363
8438
  const right = m[2] ?? "";
8364
8439
  if (/\d/.test(left) || /\d/.test(right)) continue;
@@ -8421,8 +8496,8 @@ function countPhraseTell(text2, id, phrases) {
8421
8496
  }
8422
8497
  return { count, first };
8423
8498
  }
8424
- function countPattern(text2, re) {
8425
- const matches = text2.match(re) ?? [];
8499
+ function countPattern(text2, re2) {
8500
+ const matches = text2.match(re2) ?? [];
8426
8501
  return { count: matches.length, first: matches.join("") };
8427
8502
  }
8428
8503
  function detectCopyTells(text2) {
@@ -18388,7 +18463,7 @@ Performance (the workhorse):
18388
18463
  baker ads meta insights --object act_123 --level ad --intent ranking \u2014 flag creatives to refresh
18389
18464
  baker ads meta insights --object act_123 --level adset --breakdowns publisher_platform,age \u2014 placement \xD7 age
18390
18465
  baker ads meta insights --object act_123 --level ad --since 2026-01-01 --until 2026-03-31 \u2014 auto-async
18391
- baker ads meta insights --list-presets
18466
+ baker ads meta insights --list-intents
18392
18467
 
18393
18468
  Audit & review:
18394
18469
  baker ads meta activities --account-id act_123 --days 14
@@ -20048,8 +20123,8 @@ import { defineCommand as defineCommand90 } from "citty";
20048
20123
  var GOOGLE_CSS2 = "https://fonts.googleapis.com/css2";
20049
20124
  function parseGoogleFontFaces(css) {
20050
20125
  const faces = [];
20051
- const re = /\/\*\s*([\w-]+)\s*\*\/\s*@font-face\s*\{([^}]*)\}/g;
20052
- for (const match of String(css).matchAll(re)) {
20126
+ const re2 = /\/\*\s*([\w-]+)\s*\*\/\s*@font-face\s*\{([^}]*)\}/g;
20127
+ for (const match of String(css).matchAll(re2)) {
20053
20128
  const subset = match[1] ?? "";
20054
20129
  const body = match[2] ?? "";
20055
20130
  const family = body.match(/font-family\s*:\s*['"]?([^'";]+)['"]?\s*;/i)?.[1]?.trim();
@@ -22645,8 +22720,8 @@ function parseVoiceTraits(description) {
22645
22720
  const out = {};
22646
22721
  if (/\b(female|woman|women|girl|lady)\b/.test(d)) out.gender = "female";
22647
22722
  else if (/\b(male|man|men|guy|boy)\b/.test(d)) out.gender = "male";
22648
- for (const [re, name] of LANGUAGE_WORDS) {
22649
- if (re.test(d)) {
22723
+ for (const [re2, name] of LANGUAGE_WORDS) {
22724
+ if (re2.test(d)) {
22650
22725
  out.language = name;
22651
22726
  break;
22652
22727
  }
@@ -40475,7 +40550,7 @@ registerSchema({
40475
40550
  },
40476
40551
  "from-video": {
40477
40552
  type: "string",
40478
- description: "The same, but from the VIDEO LIBRARY: comma-separated video ids (see `baker videos library`) to build on. Use this for footage the client uploaded or synced rather than something the Studio rendered. Counts against the same 10 source clips as --from-clip. The first use of a given video spends a minute encoding a reusable copy and is refused with a 'try again shortly' message; every use after that is instant.",
40553
+ description: "The same, but from the VIDEO LIBRARY: comma-separated video ids (see `baker videos search`) to build on. Use this for footage the client uploaded or synced rather than something the Studio rendered. Counts against the same 10 source clips as --from-clip. The first use of a given video spends a minute encoding a reusable copy and is refused with a 'try again shortly' message; every use after that is instant.",
40479
40554
  required: false
40480
40555
  },
40481
40556
  model: {
@@ -42118,26 +42193,85 @@ var getCommand5 = defineCommand193({
42118
42193
  import { defineCommand as defineCommand194 } from "citty";
42119
42194
 
42120
42195
  // src/commands/testimonials/emptyCorpusHints.ts
42121
- var NO_CORPUS_TAIL = 'this company has NO review corpus connected. Testimonials are ingested from the client\'s Google/Trustpilot reviews \u2014 an agent cannot write one \u2014 so no rewording will find quotes. Stop searching and source proof another way: a result or case study the client already published, a named-customer logo (`baker images logo`), or a founder claim attributed as a claim. Otherwise ship without a testimonial block and file the gap: `baker actions create --name "Collect customer quotes for <page>" --priority medium --tags <slug>`. Never invent, paraphrase-into-existence, or pad with unrelated praise.';
42122
- var LEAD2 = {
42123
- // Search reranks the whole corpus, so an empty result is a property of the
42124
- // corpus far more often than of the query — but only an unfiltered `list`
42125
- // proves it, so send it there once rather than letting it re-query.
42126
- search: "No hits. Search reranks the whole corpus, so an empty result is nearly always an empty library rather than a missed query \u2014 confirm once with `baker testimonials list --limit 1`. If that is empty too, ",
42127
- list: "No rows on an unfiltered browse, which is proof rather than a hint: "
42128
- };
42129
- function emptyCorpusHints({ command, resultCount, activeFilters }) {
42196
+ function resolveEmptyReason({
42197
+ resultCount,
42198
+ activeFilters,
42199
+ sourceCount
42200
+ }) {
42130
42201
  if (resultCount > 0) {
42131
- return [];
42202
+ return null;
42132
42203
  }
42133
- const hints = [];
42134
42204
  if (activeFilters.length > 0) {
42135
- hints.push(
42205
+ return "filtered_out";
42206
+ }
42207
+ return sourceCount === 0 ? "no_corpus_connected" : "corpus_empty";
42208
+ }
42209
+ var NO_CORPUS_EXIT = 'Testimonials are ingested from the client\'s Google/Trustpilot reviews \u2014 an agent cannot write one \u2014 so no rewording will find quotes. Stop searching and source proof another way: a result or case study the client already published, a named-customer logo (`baker images logo`), or a founder claim attributed as a claim. Otherwise ship without a testimonial block and file the gap: `baker actions create --name "Collect customer quotes for <page>" --priority medium --tags <slug>`. Never invent, paraphrase-into-existence, or pad with unrelated praise.';
42210
+ function emptyReasonDetail(reason, sourceCount, activeFilters) {
42211
+ if (reason === "filtered_out") {
42212
+ return `No rows matched, and ${activeFilters.join(", ")} narrowed this read. The corpus was not checked \u2014 drop every filter to see whether the library itself is empty.`;
42213
+ }
42214
+ if (reason === "no_corpus_connected") {
42215
+ return "This company has no review source connected \u2014 checked against its source list, not inferred from the empty result.";
42216
+ }
42217
+ return `${sourceCount} review source${sourceCount === 1 ? " is" : "s are"} connected but hold no testimonials yet \u2014 the source is there, the rows are not. Ingestion may still be running.`;
42218
+ }
42219
+ async function measureEmptyCorpus({
42220
+ command,
42221
+ resultCount,
42222
+ activeFilters
42223
+ }) {
42224
+ if (resultCount > 0) {
42225
+ return { hints: [] };
42226
+ }
42227
+ if (activeFilters.length > 0) {
42228
+ const reason2 = "filtered_out";
42229
+ return {
42230
+ hints: emptyCorpusHints({ command, reason: reason2, sourceCount: 0, activeFilters }),
42231
+ empty: { reason: reason2, detail: emptyReasonDetail(reason2, 0, activeFilters) }
42232
+ };
42233
+ }
42234
+ let sourceCount;
42235
+ try {
42236
+ const sources = await apiGet("/api/testimonials/sources");
42237
+ sourceCount = Array.isArray(sources) ? sources.length : 0;
42238
+ } catch {
42239
+ return {
42240
+ hints: [
42241
+ "No rows, and the check for connected review sources failed \u2014 so this is NOT evidence that the client has no reviews. Do not record an absence you could not verify. Re-run once; if it fails again, say the library could not be read rather than that it is empty."
42242
+ ]
42243
+ };
42244
+ }
42245
+ const reason = resolveEmptyReason({ resultCount, activeFilters, sourceCount });
42246
+ if (reason === null) {
42247
+ return { hints: [] };
42248
+ }
42249
+ return {
42250
+ hints: emptyCorpusHints({ command, reason, sourceCount, activeFilters }),
42251
+ empty: { reason, detail: emptyReasonDetail(reason, sourceCount, activeFilters) }
42252
+ };
42253
+ }
42254
+ function emptyCorpusHints({
42255
+ command,
42256
+ reason,
42257
+ sourceCount,
42258
+ activeFilters
42259
+ }) {
42260
+ if (reason === null) {
42261
+ return [];
42262
+ }
42263
+ if (reason === "filtered_out") {
42264
+ return [
42136
42265
  `No rows \u2014 ${activeFilters.join(", ")} narrowed this. Drop every filter and re-run once before touching the query; a filter combination and an empty library look identical from here.`
42137
- );
42266
+ ];
42138
42267
  }
42139
- hints.push(`${LEAD2[command]}${NO_CORPUS_TAIL}`);
42140
- return hints;
42268
+ const lead = command === "search" ? "No hits" : "No rows";
42269
+ if (reason === "no_corpus_connected") {
42270
+ return [`${lead}, and this company's source list is empty: there is no review corpus to search. ${NO_CORPUS_EXIT}`];
42271
+ }
42272
+ return [
42273
+ `${lead}. ${sourceCount} review source${sourceCount === 1 ? " is" : "s are"} connected but no testimonials have landed yet \u2014 so this is a timing or ingestion gap, not a missing connection, and re-querying will not change it. Ingestion may still be running: say so rather than reporting the client has no reviews. ${NO_CORPUS_EXIT}`
42274
+ ];
42141
42275
  }
42142
42276
 
42143
42277
  // src/commands/testimonials/list.ts
@@ -42212,14 +42346,14 @@ var listCommand18 = defineCommand194({
42212
42346
  run: async ({ args }) => {
42213
42347
  try {
42214
42348
  const data = await apiGet("/api/testimonials", buildListParams(args));
42215
- const hints = emptyCorpusHints({
42349
+ const { hints, empty } = await measureEmptyCorpus({
42216
42350
  command: "list",
42217
42351
  resultCount: data.length,
42218
42352
  activeFilters: activeFilterFlags(args, FILTER_FLAGS)
42219
42353
  });
42220
42354
  const outputFormat = args.output || "json";
42221
42355
  writeOutput(
42222
- { ok: true, data, ...hints.length > 0 ? { hints } : {} },
42356
+ { ok: true, data, ...empty ? { meta: { empty } } : {}, ...hints.length > 0 ? { hints } : {} },
42223
42357
  outputFormat,
42224
42358
  args.fields ? args.fields.split(",") : void 0,
42225
42359
  args.full,
@@ -42351,17 +42485,15 @@ var searchCommand3 = defineCommand195({
42351
42485
  const body = buildSearchRequest(query, args);
42352
42486
  const data = await apiPost("/api/testimonials/search", body);
42353
42487
  const languageHint = languageBiasHint(data, body.language);
42354
- const hints = [
42355
- ...emptyCorpusHints({
42356
- command: "search",
42357
- resultCount: data.length,
42358
- activeFilters: activeFilterFlags(args, FILTER_FLAGS2)
42359
- }),
42360
- ...languageHint ? [languageHint] : []
42361
- ];
42488
+ const { hints: emptyHints, empty } = await measureEmptyCorpus({
42489
+ command: "search",
42490
+ resultCount: data.length,
42491
+ activeFilters: activeFilterFlags(args, FILTER_FLAGS2)
42492
+ });
42493
+ const hints = [...emptyHints, ...languageHint ? [languageHint] : []];
42362
42494
  const outputFormat = args.output || "json";
42363
42495
  writeOutput(
42364
- { ok: true, data, ...hints.length > 0 ? { hints } : {} },
42496
+ { ok: true, data, ...empty ? { meta: { empty } } : {}, ...hints.length > 0 ? { hints } : {} },
42365
42497
  outputFormat,
42366
42498
  args.fields ? args.fields.split(",") : void 0,
42367
42499
  args.full,