# Google Ads Audit

> Audits and scores an existing Google Ads account across conversion tracking, wasted spend, account structure, keywords/Quality Score, ads & assets (incl. PMax, AI Max, Demand Gen), and settings. Read-only. Generalized fork of the ads-google audit: reads everything from account-context.yaml and applies the operator's guardrails — no brand/vertical hardcoding. Use when the user says "audit", "score my account", "what's wrong with my ads", "wasted spend", "account review", "health check".

- Skill: `chanktb/google-ads-audit` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add chanktb/google-ads-audit`
- Raw SKILL.md: https://api.skillmd.com/api/skills/chanktb/google-ads-audit/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: chanktb (https://skillmd.com/u/chanktb)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/chanktb/google-ads-audit

---


# Google Ads — Audit

Read-only diagnostic. Score the account, surface issues by severity, and — critically — **never fire a
finding on an unverified assumption**. Reuse the live MCP detection proven in `setup`.

## STEP 0 — Load context (MANDATORY, before any scoring)
1. Read `account-context.yaml` from the working directory. If missing, run `setup` first.
2. Pull from it: `google_ads.customer_id`, `brand_terms`, `competitor_terms`, `margin_tiers`,
   `data_source` (for landing-page URL checks), `measurement`, and **`guardrails`**.
3. Output is written to the **working directory** (`GOOGLE-ADS-AUDIT.md`), never into the plugin.

## ⚠️ The six guards (apply to EVERY check — they prevent the most common wrong findings)
These are generic; `guardrails` in the context may strengthen or add to them. Honor both.

- **GUARD-1 Conversions — check the WHOLE picture, not the action list.** Inspect *campaign-level*
  conversion goals + which actions each active campaign actually fires (segment by
  `segments.conversion_action_name`), and each action's source. A primary action no campaign uses is
  inert; a second purchase action kept *secondary* (e.g. GA4 purchase alongside the Shopify channel "App
  Purchase") is the CORRECT anti-double-count setup, not a bug. Only flag a real micro-conversion goal
  or two value-carrying purchase sources both primary and both firing.
  **Before flagging ANY conversion-action contamination (out-of-brand / micro / duplicate), check
  `primary_for_goal`.** A non-primary action does not enter Smart Bidding and does not affect performance —
  it's harmless; surface it as a NOTE (tidy-up), never a scored finding. Only a PRIMARY out-of-brand/micro
  action that an active campaign optimizes toward is a real issue. See `${CLAUDE_PLUGIN_ROOT}/references/conversion-tracking-logic.md`.
- **GUARD-2 Negatives — FOUR sources, merge ALL.** (1) `campaign_criterion` (negative=true) — campaign-level;
  (2) `campaign_shared_set` → `shared_set` → `shared_criterion` — SHARED LISTS (the bulk: e.g. "Account Level
  Negative", "OPI Negative", brand blocks); (3) `customer_negative_criterion` — ACCOUNT-LEVEL negatives applied
  account-wide; (4) `campaign_criterion.brand_list.shared_set` — brand exclusions. Only flag a gap if terms are
  absent across ALL FOUR; if a shared/account list covers them, cite it, don't flag. **A `campaign_criterion`-only
  pull is the #1 false-positive in this audit — it looks "thin" when coverage is actually 100+ terms/campaign.**
  After merging all four, declare `pulled:[...,"negatives_complete"]` so the report renders a real verdict;
  without that assertion a campaign-only bundle renders VERIFY (re-pull), never a false "weak negatives".
- **GUARD-3 Budget scaling — check `change_event` first.** Before recommending a budget/bid increase,
  pull recent change history (~14d). A recent budget/bid/asset/structure change → recommend cooldown,
  not scale. (Strengthen with any `change-event-cooldown` guardrail in context.)
- **GUARD-4 Ad strength — confirm ENABLED + impressions.** Before flagging POOR ad strength, confirm
  `ad_group.status` AND `ad_group_ad.status` are ENABLED and the group has impressions. Paused =
  intentional; do not flag.
- **GUARD-5 ROAS — apply margin tiers from context.** Read `margin_tiers`; a line with a tier accepts its
  `min_roas` (house brands run lower ROAS by design). Never apply a generic ROAS threshold to such a
  line or a strategic campaign. If unsure, ASK rather than flag.
- **GUARD-6 No false "absence".** MCP results can be truncated (token cap or your `limit`). Before
  claiming "no video / no signal / no negative / no X", re-query filtered on the enum NAME (e.g.
  `field_type = 'YOUTUBE_VIDEO'`) or pull with returned-rows < limit (proof of completeness). NEVER
  infer presence/absence by decoding enum integers from memory. Track failed fetches as a G-SYS1
  diagnostic; never silently skip a check — explain why data is unavailable.

## Audit scope — only what's LIVE in the window (build this FIRST, before any check)
Establish the **active campaign set**: campaigns that are ENABLED **AND had impressions > 0 during the
audit window**. Exclude removed, paused, and ENABLED-but-dormant (zero-impression / long-off) campaigns.
Then **scope EVERY other pull to that set** — asset groups, ad groups, ads/ad-strength, keywords, search
terms. Entity-level `status = ENABLED` is NOT sufficient: a campaign turned off years ago can still contain
ENABLED asset groups / ad groups, and auditing them produces stale, misleading findings. Filter downstream
queries by `campaign.id IN (active set)`, or pull them WITH metrics over the window and drop zero-impression
rows app-side (metrics can't go in a GAQL WHERE). Never audit a campaign that hasn't served in the window.

## Model dispatch (run cheap, decide expensive) — see `${CLAUDE_PLUGIN_ROOT}/references/model-tier-dispatch.md`
- **Scout (`haiku`)** — STEP 0 context read; the `money_leak_report.py` render (→ the single `AUDIT.html`) after the Judge writes `audit-result.json`; single URL/landing 200 checks.
- **Routine (`sonnet`)** — build the active-campaign set; the account data-bundle pull (campaigns, assets, change history); the dual-source search-term pull (per-PMax `campaign_search_term_insight` loop). Dispatch as `general-purpose` sub-agents (they keep MCP); tell them to **return raw rows, not score**.
- **Judge (main session)** — applying GUARD-1…6, every PASS/WARN/FAIL call, the score, the Gap-to-100 ledger, Quick Wins. **Never delegate a guard or a verdict** — a Scout pulls numbers, the Judge says what they mean.

## Process
1. **Pre-flight**: load context + guards; **build the active campaign set** (above); stage the GUARD data
   pulls (campaign conversion goals, merged negatives, `change_event`, `ad_group.status`) scoped to it.
2. **Collect** account data via the Google Ads MCP, **scoped to the active campaign set**. Honor GAQL gotchas
   in `${CLAUDE_PLUGIN_ROOT}/skills/audit/references/gaql-notes.md` (dedup keywords; ENABLED-only +
   impressions-in-window; explicit YYYY-MM-DD date ranges; `search_term_view` can't use a 90-day DURING).
   **MANDATORY pulls — a missing one = a false finding, not a clean account (this is the checklist a dispatched
   Scout MUST be handed in full):**
   - campaigns + metrics (active set), budgets, bidding strategy + tROAS
   - conversion_action (account) + per-campaign `segments.conversion_action_name` usage (GUARD-1)
   - asset_group / ad_group_ad ad-strength; keyword_view + Quality Score (deduped)
   - **campaign extensions coverage** (D9): `SELECT campaign.id, campaign.status, campaign_asset.field_type,
     campaign_asset.status FROM campaign_asset WHERE campaign.status='ENABLED' AND campaign_asset.status='ENABLED'`.
     `field_type` gives the coverage (SITELINK / CALLOUT / STRUCTURED_SNIPPET / PRICE / IMAGE). **Two gotchas,
     both seen live:** (1) `campaign.status` MUST be in the SELECT because it's filtered (else
     `EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE`); (2) do NOT also select `asset.type` here — it throws
     INVALID_ARGUMENT on many MCPs (pull asset text separately if needed). Count `field_type` **per campaign**
     (not just presence) so "thin extensions" findings use real counts. If it still errors, flag extensions
     **UNVERIFIED — verify in UI**, never assume present/absent.
   - search terms **both** sources: `search_term_view` + per-PMax `campaign_search_term_insight`
   - **negatives from ALL FOUR sources (GUARD-2) — NOT just `campaign_criterion`:**
     (a) `campaign_shared_set` (which shared lists attach to each campaign — **count only `status=2` ENABLED;
     many lists are `status=3` REMOVED/old**) → `shared_criterion` (the terms in each ENABLED list);
     (b) `customer_negative_criterion` (account-level negatives applied to every campaign — a separate resource,
     easy to miss; the first ND pull omitted it and produced a false "weak negatives" finding that had to be
     corrected mid-audit). A brand block / cross-brand block / location block usually lives in a SHARED or
     ACCOUNT-level list, so a `campaign_criterion`-only pull shows "no negatives" when coverage is actually
     extensive. **Never flag G07/G14/G-PM5/G-PM6 (brand exclusion / negative coverage) without having pulled
     `shared_criterion` AND `customer_negative_criterion`. After merging, declare `pulled:[...,"negatives_complete"]`.**
   - **PMax Brand exclusions are a SEPARATE layer from negative keywords — pull them explicitly:**
     `campaign_criterion` selecting `campaign_criterion.brand_list.shared_set` + `.negative`
     (filter `campaign_criterion.brand_list.shared_set IS NOT NULL`), then resolve each `shared_set.name`.
     These rows have an EMPTY `keyword.text`, so a keyword-text-only pull DROPS them and you'll wrongly
     report "no brand exclusion". Check whether a brand list matching the own brand (a list named after the
     own brand) is excluded on each PMax campaign. An account can run BOTH a brand negative-keyword list AND a brand-list
     exclusion (belt-and-suspenders) — read both before judging.
   - `change_event` (~14d)
   - **channel split** (D8): `segments.ad_network_type` per campaign — **this WORKS for PMax too** (network 2
     Search/Shopping dominates; small YouTube/Display/cross). NEVER report channel as "verify in UI". Pull
     `metrics.conversions` alongside cost — a cost-only pull makes every network look like 0-conv burn.
   - **device split** (D10): `segments.device` per campaign (2 Mobile · 3 Tablet · 4 Desktop · 5 CTV · 6 Other).
   - **dayparting** (D3/D4): `segments.hour` + `segments.day_of_week` per campaign + metrics — for the hourly
     heatmap and best/worst hour/day. And **ad schedule**: `campaign_criterion.ad_schedule.*` + `bid_modifier`
     (a flat `bid_modifier=0` across the window = NO dayparting bid strategy — a real opportunity finding).
   - **geo — ALL regions** (D3): `geographic_view` scoped to active campaigns, `metrics.cost_micros > 0`, **no
     top-N limit**. Then resolve EVERY `segments.geo_target_region` id to a name via `geo_target_constant`
     (id, name, canonical_name). Raw `geoTargetConstants/NNNNN` ids in the report = an unfinished pull, never ship them.
   - **asset TEXT + per-asset metrics** (D9) — pull the TEXT and cost, NOT just counts (a counts-only pull is
     wrong; the verbatim text + per-asset spend is what surfaces "this headline spent $X at 0 conv"):
     `SELECT campaign.id, asset_group_asset.field_type, asset.text_asset.text, metrics.cost_micros,
     metrics.conversions, metrics.conversions_value, metrics.clicks FROM asset_group_asset WHERE
     campaign.status='ENABLED' AND asset_group_asset.status='ENABLED' AND segments.date BETWEEN ...` (HEADLINE
     ft2 / DESCRIPTION ft3 / LONG_HEADLINE ft18). `asset_group_asset` DOES return per-asset metrics — a
     text-only or count-only pull just *looks* empty (gaql-notes). For **Search RSAs**, `ad_group_ad.ad.
     responsive_search_ad.headlines` is a RepeatedComposite the serializer can't return — get RSA asset text +
     metrics from **`ad_group_ad_asset_view`** instead (don't write "verify in UI"). The ONLY genuine UI-only
     asset field is `asset_group_asset.performance_label` (UNRECOGNIZED on some API versions).
   - **extension TEXT** (D9) — not just field_type counts: pull the sitelink/callout/snippet/price **text**
     so the report shows the actual extensions. Either `campaign_asset` joined to the `asset` text fields
     (`asset.sitelink_asset.link_text`, `asset.callout_asset.callout_text`, `asset.structured_snippet_asset.values`,
     `asset.price_asset.*`) or the `asset` resource by id. bundle key `ext_text:[{campaign_id,field_type,text}]`.
   - **conversion lag** (D12): `segments.conversion_lag_bucket` — the "can I trust short-window ROAS?" gate.
   - **settings & measurement that DO read** (pull these — they were once wrongly punted to UI): **location type**
     `campaign.geo_target_type_setting.positive_geo_target_type` — **official enum: 5=PRESENCE_OR_INTEREST,
     6=SEARCH_INTEREST (both leaks → recommend Presence-only), 7=PRESENCE (correct, NOT a leak)**. Do NOT invert
     this: 7 is the GOOD value. (Verified live + against the v21 proto 2026-06-28.) **Enhanced Conversions** `customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled`
     + `.accepted_customer_data_terms`; **final URLs** via `landing_page_view` / `expanded_landing_page_view`;
     **RSA text + per-asset metrics** via `ad_group_ad_asset_view`; **placement exclusions** via
     `campaign_criterion.placement.url`; **content-label exclusions** (digital content labels DV-G/PG/T/MA) via
     `campaign_criterion` WHERE `type='CONTENT_LABEL'` (`content_label.type`) — **this READS; an empty result =
     none configured, NOT unreadable** (do not punt it to UI). bundle key `content_labels:[{campaign_id,content_label_type}]`.
     (Exact queries: `references/gaql-notes.md`.)
   - **competitive pressure — impression share (PUBLIC, always pull)** (D-competitive): per active campaign pull
     `metrics.search_impression_share`, `metrics.search_rank_lost_impression_share`,
     `metrics.search_budget_lost_impression_share` (+ abs-top) with a date range. rank-lost IS = losing the
     auction to COMPETITORS on Ad Rank; budget-lost IS = losing to budget (a scaling lever). Put on each
     `active_campaigns` row. These are public metrics — never punt to UI.
   - **competitor DOMAINS — Auction Insights (try, then degrade)** (competitor input): the "phiên đấu giá"
     competitor list IS in the API — `segments.auction_insight_domain` + `metrics.auction_insight_search_*`
     (impression_share / overlap_rate / outranking_share / position_above_rate / top & abs-top %). **BUT these
     are RESTRICTED "not publicly available" metrics → most dev tokens get `METRIC_ACCESS_DENIED` unless Google
     allowlisted the token.** So: TRY the pull; on success write bundle key `auction_insights:[{domain,
     impression_share,overlap_rate,outranking_share,position_above_rate}]` (the report renders a Competitors card
     and you MERGE the domains into the setup competitor list). On `METRIC_ACCESS_DENIED`, do NOT call it an API
     gap — note "Auction Insights metrics not allowlisted on this token" and fall back to the impression-share
     signal above + the competitors the user gave at setup. (Set `meta.own_domain` so your own row is tagged.)
   - **⚠️ "verify in UI" is ONLY these confirmed-blocked fields** (every variant tested 2026-06-28) — do NOT
     mark anything else UI-only: Final URL Expansion toggle (`url_expansion_opt_out` UNRECOGNIZED), asset
     automation (`asset_automation_settings` PROHIBITED/RepeatedComposite), the **video brand-safety inventory
     mode** (`campaign.video_brand_safety_suitability` UNRECOGNIZED on this version — Expanded/Standard/Limited),
     PMax `asset_group_asset.performance_label` (UNRECOGNIZED — use
     `asset_group.ad_strength` + per-asset cost/conv), and **Consent Mode v2 / server-side-CAPI** (tag-side by
     design — verify in GTM, NOT a pull failure). EVERYTHING ELSE is in the API: a fetch that returns nothing =
     wrong method (field/resource/missing `metrics.*`/enum-to-drop), **retry** — see `references/gaql-notes.md`.
     A "we can't see it, check the UI" on API-available data is the #1 way this audit loses a user's trust.
   - **GAQL gotcha:** any field used in a `WHERE` filter MUST also appear in the `SELECT` clause, or the API
     returns `EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE` (e.g. filtering on `campaign.status` requires
     selecting it). If a field is rejected, drop just that field and re-run — don't abandon the whole pull.
   - **COMPLETENESS — loop every campaign, never truncate.** Per-campaign pulls (`asset_group_signal`,
     `asset_group_asset`, `campaign_search_term_insight`) must cover **ALL** PMax/Search campaigns — do NOT stop
     "to save time" at the first few. A partial pull (e.g. signals for 3 of 7 PMax) makes the generator emit
     FALSE "0 audience signals" findings for the un-pulled ones. Big results: save to a file + parse, don't drop.
   - **DECLARE what you pulled.** `bundle.json` MUST carry a top-level **`pulled: [...]`** listing every
     dimension you FULLY pulled (all campaigns) — e.g. `["active_campaigns","channel","device","geo","dayparting",
     "schedule","extensions","signals","negatives","assets","products","conversion_lag","change_events"]`.
     **Omit any dimension you could not complete** — the generator renders those VERIFY ("not pulled — re-run")
     instead of fabricating GOOD/FIX. Honesty-by-construction: an absent `pulled` entry is safe; a half-filled
     dimension passed off as complete is what burns the user.
3. **Validate coverage**: confirm ≥30 days of data and a Search Terms Report before scoring; if a source
   is down, degrade gracefully (flag it, continue) per the fallback chain in `setup`.
4. **Evaluate** each applicable check as PASS / WARNING / FAIL — applying GUARD-1…6 so nothing fires on an
   assumption. Use `${CLAUDE_PLUGIN_ROOT}/skills/audit/references/google-audit-checks.md` for the check catalog and severities.
5. **Score** per `${CLAUDE_PLUGIN_ROOT}/skills/audit/references/scoring-system.md` (weighted by severity × category).
6. **Report** with the per-category Gap-to-100 ledger and an action plan.

## Money-Leak deep-dive (MANDATORY — this is what makes the audit worth paying for)
Beyond scoring, run the money-leak diagnostics in `${CLAUDE_PLUGIN_ROOT}/references/diagnostic-playbook.md`
(D1 bid/target health · D2 budget pacing & allocation · D3 geo waste · D4 dayparting · D5 search-term/spam/
wrong-brand · D6 structure/setup · D7 Quality Score · D8 PMax channel/placement · D9 ad copy/assets/extensions ·
D10 settings hygiene · D11 audience signals/search themes · D12 conversion-lag gate · D13 change-history timeline ·
**D14 product feed & Shopping performance**). **For ECOM, D14 is the highest-$ diagnostic.** It has THREE
layers, each from a different source — degrade cleanly, don't lump them as "needs Merchant":
  1. **Product PERFORMANCE/identity (Ads, always available)** — `shopping_performance_view` returns full product
     data through the Merchant LINK (item_id, title, brand, type, condition + cost/impr/conv). The "which product
     burns / converts" leak. The products ARE in Google Ads — never say "no product data".
  2. **Out-of-stock (STORE connector)** — cross-ref the spending item_ids against the store's live inventory
     (Shopify/Woo). OOS spenders = pure waste, and this needs NO Merchant connector — only the store you already
     connected. Do this whenever a store is connected.
  3. **Approval STATUS (Merchant Content API only)** — disapproved / GTIN / policy issues are NOT a GAQL field.
     If a Merchant connector is present, pull it; if not, flag ONLY this layer as verify-via-Merchant (not the
     whole of D14).
  **D14 gets its OWN rendered HTML — `D14-PRODUCT-CHECK.html` — not just markdown.** When a store is connected,
  join the zero-conversion burn products (layer 1) against live store inventory (layer 2) into ONE per-item JSON
  (one row per burning product) with these fields per item: `campaign`, `title`, `brand`, `cost_30d`,
  `clicks_30d`, `imp_30d`, `product_onlineStoreUrl` (the live PDP link — MANDATORY so the user can click to
  check each SKU), `variant_sku`, `variant_price`, `variant_compareAtPrice`, `variant_inventoryPolicy`,
  `total_available_all_locations`, `classification` (`OOS_DENY` | `LOW_STOCK` | `IN_STOCK_NEEDS_PDP_AUDIT`),
  `reason`. Then render:
  `python ${CLAUDE_PLUGIN_ROOT}/skills/audit/scripts/d14_product_report.py <items>.json --out-dir <workdir>
  --account "<name>" --window "<range>"` → a visual, self-contained `D14-PRODUCT-CHECK.html`: KPI strip, one
  color-coded section per classification (OOS=red / low-stock=amber / in-stock=blue), a burn bar per row, and a
  **full SKU appendix where every product title links to its live storefront URL**. ALWAYS pull the PDP URL +
  SKU (`product.onlineStoreUrl` / variant SKU from the store) — a D14 product list without clickable URLs is
  incomplete; the user must be able to verify each item in one click. Each diagnostic yields a **Money-Leak
  Report** row: leak · evidence
(real numbers) · diagnosis (root cause, not symptom) · **$/month at risk** (formula shown) · exact fix ·
discipline · confidence. Decode `bidding_strategy_system_status` FIRST (one field separates budget-capped vs
tROAS-too-high vs starved vs learning). A generic "score 85" without these specific, dollar-quantified leaks is
the low-value audit we are explicitly NOT shipping. The diagnostics feed both the category scores and the
Money-Leak Report in the output.

**One-command pipeline (model-tier dispatch) → ONE comprehensive report:** dispatch a **Routine** sub-agent
(`general-purpose`, sonnet) to run the PULL MANIFEST in
`${CLAUDE_PLUGIN_ROOT}/skills/audit/scripts/money_leak_report.py` (its docstring lists every GAQL query + the
`bundle.json` schema) → it writes `bundle.json`. **Write `audit-result.json` (the score — STEP 5) into the SAME
directory** so the generator merges score + money-leak. Then **Scout**-run
`python ${CLAUDE_PLUGIN_ROOT}/skills/audit/scripts/money_leak_report.py bundle.json --out-dir <workdir>` to
generate `MONEY-LEAK-REPORT.md` + `DETAILED-ACCOUNT-REPORT.md` AND the **single comprehensive `AUDIT.html`** —
score donut + grade, the 6 weighted category bars (fixable/unverified findings), money-leak $ chart + findings,
a **visual per-campaign section** (ROAS-vs-target bar with target tick, status pill, channel-split bar,
extension/signal chips), Merchant feed table, quick wins, change timeline, verify-in-UI. It auto-detects
`audit-result.json` next to the bundle (or pass `--audit-result`); without it AUDIT.html degrades to
money-leak-only. **AUDIT.html is THE — and the ONLY — HTML deliverable: one file, score + full check + leaks +
per-campaign visuals together.** Do not render any second HTML report (no `audit-report.html`); there is exactly
one HTML output per audit.
Then the **Judge** (you) adds verify-in-UI verdicts. **When a pull returns nothing, assume wrong method (field
name / resource / missing `metrics.*` / repeated-enum serialize), not an API limit — retry before writing
"verify-in-UI"** (see gaql-notes principle).

## What to analyze (category weights)
- **Conversion Tracking (25%)** — campaign-level goals (GUARD-1), Enhanced Conversions, Consent Mode v2,
  value tracking, attribution, offline import (lead gen), conversion lag. Cross-check Google Ads vs GA4
  (flag >35% divergence) when `measurement.ga4` is enabled.
- **Wasted Spend / Negatives (20%)** — review search terms from **BOTH sources** (`search_term_view` does
  NOT contain PMax terms): `search_term_view` for Search/Branded campaigns + `campaign_search_term_insight`
  **looped per active PMax/Demand-Gen campaign** (it requires a single `campaign_id` filter; see
  gaql-notes.md). Merged negative coverage (GUARD-2), brand/non-brand separation (use `brand_terms`), broad
  match only with Smart Bidding, geo precision. Source negatives from actual irrelevant search terms; prefer
  Exact/Phrase; recommend shared lists. Only flag wasted on Search terms with >$10 spend AND 0 conv; for
  PMax, flag irrelevant categories with clicks but no conversions. **Never propose blocking brand terms.**
- **Account Structure (15%)** — business-logic organization, tightly themed ad groups, ≥3 RSAs/group,
  PMax asset-group/signal structure, naming consistency.
- **Keywords & Quality Score (15%)** — match-type strategy, QS distribution (≥7 target), cannibalization
  (watch cross-campaign + use `brand_terms`), impression share. Apply the legacy-BMM heuristic (BROAD +
  Manual CPC = legacy, not intentional broad).
- **Ads & Assets (15%)** — RSA headlines/descriptions count & strength (GUARD-4), pin discipline,
  extensions (sitelinks/callouts/snippets/image), PMax/AI Max/Demand Gen specifics (below).
- **Settings & Targeting (10%)** — Smart Bidding vs deprecated ECPC, budget pacing, ad schedule, device
  adjustments, location = "Presence" not "Presence or Interest", network settings.

## Deep dives (only if those campaign types exist)
- **PMax** — asset-group diversity, audience signals, URL-expansion control, search themes, Insights tab,
  and **brand handling — VERIFY, don't just "confirm"**. When a Brand Search campaign runs alongside PMax,
  do NOT flag "missing brand exclusion" on the surface. First DIG IN and check ALL THREE block mechanisms:
  (a) **campaign-level negative keywords** (`campaign_criterion`, `negative=true`, type KEYWORD);
  (b) a **shared negative list** (`campaign_shared_set` → `shared_criterion`) — brand blocks are MOST often a
  named shared list (e.g. "Brand Keywords — do not apply to Branded campaign") applied to PMax + non-brand campaigns
  and excluded from the Branded campaign; (c) a **brand-exclusion brand list** (brand-list criteria). Check
  whether the account's `brand_terms` are actually blocked by ANY of these.
  **If brand is already blocked → it's a PASS; re-score, don't flag.** Only flag G07/G-PM3 if brand
  terms are genuinely unblocked (and corroborate with G-PM3: are >15% of PMax conversions on brand terms?).
  Honor any `pmax-brand-exclusion` guardrail.
- **AI Max for Search** — `campaign.ai_max_setting.enable_ai_max`, broad-match+Smart-Bidding combo, search
  term matching distribution, AI Brief, text-customization rules, FUE controls, brand exclusions. DSA/ACA
  auto-migration pre-flight (Sept 2026) — stage LOW→HIGH risk; strong negatives are a prerequisite.
- **Demand Gen** — video+image asset mix, audience signals, funnel-aligned conversion goals (no frequency
  capping — monitor manually).

## Key thresholds
| Metric | Pass | Warning | Fail |
|--------|------|---------|------|
| Quality Score (avg) | ≥7 | 5-6 | <5 |
| CTR (Search) | ≥6.66% | 3-6.66% | <3% |
| CVR (Search) | ≥7.52% | 3-7.52% | <3% |
| CPC (Search) | ≤$5.26 | $5.26-8.00 | >$8.00 |
| Wasted Spend | <10% | 10-20% | >20% |
| Ad Strength | Good+ | Average | Poor |
(Thresholds are **ecommerce-Search defaults** and niches vary widely — apparel ≠ electronics ≠ supplements.
**Derive bands from the account's OWN historical data first**; fall back to these only for a brand-new account,
and state the basis. Never FAIL a campaign on a benchmark its niche legitimately runs below.)

## Report architecture — PER-CAMPAIGN VISUAL EXPLAINER (see `${CLAUDE_PLUGIN_ROOT}/references/per-campaign-report-template.md`)
The audit is a **per-campaign visual explainer**, not a generic account summary. Document order: **Account
panel → one chart-rich block per active campaign → Action Plan**.

**The 3-step rule for EVERY item — ① DATA shown → ② VERDICT (GOOD / WATCH / FIX / VERIFY) → ③ ACTION (only
if needed).** There is NO separate "money-leak summary" block: the leak IS the verdict+action on the section
it belongs to (evaluated inline). Every quantitative metric renders as its own **inline-SVG chart**, never a
wall of text:
- ROAS vs target → **semicircle gauge** (fill = ROAS, tick = target) · Budget pacing → **radial ring**
- Channel mix → **donut** · Device & Geo → **horizontal bars colored by ROAS** · Schedule → **24-hour heatmap**
- Extension/signal counts → **meter bars** vs the recommended count.
Each campaign block shows a **scorecard** (N good · N watch · N fix · N verify) and covers: performance +
decoded `bidding_strategy_system_status`, channel, device, geo, schedule, products × Merchant, audience
signals + search themes, ad copy (headline/description text), extensions **with text**, negatives + shared
lists, final URLs/FUE, change/cooldown — each field marked **[API]** or **[VERIFY]** (read-only API can't
see it). The **account panel lists & rates EVERY account-level setting** (conversion tracking, Enhanced
Conv, Consent, account/brand negative lists, Content Suitability, structure, conversion-lag) the same 3-step
way. **Localize per campaign**; only cross-campaign / account-wide items (budget misallocation, tracking,
content suitability, structure) sit in the account panel. Flat/modern style (no heavy gradients).

The blueprint (every section, its chart, data source, verdict thresholds) is
`references/per-campaign-report-template.md`. The generator (`money_leak_report.py`) builds it: account
panel + Σ(campaign block) + action plan → one self-contained `AUDIT.html`. It auto-detects `audit-result.json`
next to the bundle for the score + the account panel's category verdicts.

## Output — `GOOGLE-ADS-AUDIT.md` (to the working dir)
1. **Health Score** 0-100 + grade, with the six category bars.
2. **Per-category Gap-to-100 ledger** (MANDATORY for any category < 100). Split every non-PASS into:
   - 🔧 **Fixable (verified)** — check ID, status, severity, **points recoverable**, concrete fix.
   - 🔍 **Unverified** — API/MCP can't see it (Enhanced Conversions recording, Consent Mode mode, gtag
     firing). Do NOT score as FAIL; mark "verify in UI" and state the ceiling if confirmed. A low score
     driven by unverified items is not breakage — say so.
3. **Money-Leak Report** (the headline deliverable) — the D1-D14 findings as rows ranked by $/month
   recoverable, each with evidence + root-cause diagnosis + exact fix + discipline + confidence. Top 3 = Quick
   Wins. This is what the operator reads first.
4. **Detailed Account Report** (the differentiated value — granular, not just a score). Beyond the summary:
   - **Account summary** + *what is protecting it* (e.g. "no Display burn BECAUSE Content Suitability blocks junk
     apps" — explain the cause, don't just report the healthy symptom).
   - **Per active campaign**, a deep ledger: bidding/tROAS + **decoded `bidding_strategy_system_status`**, ROAS
     vs target, channel split (D8), device split (D10), geo top/bottom (D3), dayparting flags (D4), extension
     inventory — sitelinks/callouts/snippets counts (D9), ad-copy notes incl. duplicates/Ad-Strength (D9), and
     the **settings layer (D10)** — Final URL Expansion + URL exclusions, asset automation / text customization,
     text/brand guideline (exists? appropriate?), content-suitability / placement exclusions, device exclusions.
     **Mark each field API-read or verify-in-UI.**
   - **Per-campaign Verify-in-UI checklist** — the settings the read-only API can't see (FUE, asset automation,
     content suitability, text-guideline content) listed as explicit checkboxes. Never silently skip a setting.
5. Wasted-spend estimate (monthly $), PMax/AI Max notes.
6. **Findings feed `optimizer` and `plan`** — but audit itself changes nothing.

### Also emit a structured result — it feeds the ONE comprehensive HTML report
- Write **`audit-result.json`** (schema: `${CLAUDE_PLUGIN_ROOT}/templates/audit-result.json`): health_score, grade, per-category
  scores + fixable/unverified findings, quick_wins, wasted_spend_monthly, notes. **Put it next to `bundle.json`.**
- The client report is **`AUDIT.html`** from `money_leak_report.py` (above): a **per-campaign visual explainer**
  that auto-merges this score JSON (account panel + category verdicts) with the per-campaign chart blocks
  (gauge / ring / donut / bars / heatmap) where every item is **data → verdict → action**. ONE self-contained
  file. **Do NOT ship the score and the money-leak as two separate HTML files** — the audit is one document.
- **There is exactly ONE HTML report: `AUDIT.html`.** The old score-only `audit_to_html.py`/`audit-report.html`
  path has been REMOVED — do not look for it or generate a second file.

## To build / refine later
- [x] **Per-campaign visual-explainer `AUDIT.html`** — `scripts/money_leak_report.py` renders the chart-rich,
  data→verdict→action report (gauge/ring/donut/bars/heatmap + account panel + scorecards). This is the SOLE HTML
  report; the legacy `audit_to_html.py` was removed. Done.
- [x] Full granular check-ID catalog (G01…G61 + extensions) in `${CLAUDE_PLUGIN_ROOT}/skills/audit/references/google-audit-checks.md`. Done.
- [ ] Vertical-specific benchmark bands in `${CLAUDE_PLUGIN_ROOT}/references/`.
- [ ] Per-keyword QS + RSA pin/headline-count detail for Search campaigns (currently summarized).
- [ ] Geo as a US choropleth (currently colored bars).

