agentleFS
loglylogly/mureo/AGENTS.md

mureo

AGENTS.md64 starsChanged 7 months agoApache-2.0View on GitHub

Guidelines for AI agents contributing to the mureo codebase. mureo — your local-first AI ad ops crew. Find waste, audit changes, run ad accounts safely. Works with Claude Code, Cursor, Codex & Gemini. mureo sits on top of the official ad-platform MCPs and gives your AI a strategy to follow, an outcome to be measured against, and an audit trail you can show to anyone — credentials never leave your machine. mureo combines strategy context, workflow commands, and domain knowledge…

  • Reads credentials
  • Installs packages

The file

We split this AGENTS.md into the rules it gives an agent

12 rules. About 1 is common, 6 are distinctive, and 5 are very specific to this project.

  • ≈1

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈6

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Reuse before writing: Before adding a helper, search for one with the same purpose — not only the same name — and diff the bodies.

    line 516
  2. Very specific:

    One name per concept: Check the existing vocabulary (period, customer_id, account_id, platform, …) before introducing a parameter, field or …

    line 518
  3. Very specific:

    After git add, before git commit, invoke python-reviewer (or the language-appropriate code-reviewer) via the Agent tool.

    line 532
  4. Distinctive:

    If the reviewer reports HIGH or CRITICAL findings, fix them and re-run the review before committing — fixes can introduce new issues.

    line 534
  5. Distinctive:

    Only commit after the review is clean.

    line 535
  6. Very specific:

    Run the suite in a clean virtualenv (mureo + [dev], nothing else).

    line 567
  7. Distinctive:

    All external API calls (Google Ads, Meta Ads) must be mocked in tests

    line 572
  8. Very specific:

    Use @pytest.mark.unit / @pytest.mark.integration for categorization

    line 573
  9. Common:

    Never hardcode API keys, tokens, or secrets in source code

    line 647
  10. Very specific:

    When working on mureo:

    line 660
  11. Distinctive:

    Never interpolate user input directly into GAQL strings

    line 671
  12. Distinctive:

    Use parameterized values for customer_id, campaign_id, etc.

    line 672

≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
AGENTS.md

AGENTS.md

Guidelines for AI agents contributing to the mureo codebase.

Project Overview

mureo — your local-first AI ad ops crew. Find waste, audit changes, run ad accounts safely.

Works with Claude Code, Cursor, Codex & Gemini. mureo sits on top of the official ad-platform MCPs and gives your AI a strategy to follow, an outcome to be measured against, and an audit trail you can show to anyone — credentials never leave your machine.

mureo combines strategy context, workflow commands, and domain knowledge to help AI agents achieve marketing goals across platforms. Provides MCP tools for direct platform operations and workflow commands for strategy-driven ad operations via Claude Code slash commands. Designed for AI agents — no database, no LLM SDK, no web framework.

Build & Test

pip install -e ".[dev]"
pytest tests/ -v
pytest tests/ --cov=mureo --cov-report=term-missing

# Browser assets (only when mureo/_data/web/ changes). Node's built-in
# runner — no package.json, no dependencies, no build step.
node --test tests/js/*.test.js

Architecture

mureo/
├── google_ads/          # Google Ads API client (Mixin composition)
│   ├── client.py        # GoogleAdsApiClient (main entry)
│   ├── mappers.py       # Response mapping to structured dicts
│   ├── _enum_names.py   # map_enum_name + SDK-derived int->name maps (raw protobuf has no .name, #588)
│   ├── _placement_mappers.py # Negative-placement + group_placement_view row mappers (#544/#547)
│   ├── _ads.py          # AdsMixin (RSA create/update/status/list)
│   ├── _ads_display.py  # DisplayAdsMixin (RDA create + RDAUploadError)
│   ├── _asset_groups.py # AssetGroupsMixin (Performance Max asset groups: one read for
│   │                    #   text + images, and the text swap via one atomic
│   │                    #   GoogleAdsService.mutate, #590/#626). Owns both field-type
│   │                    #   tables — text width limits and image dimension rules
│   ├── _asset_groups_images.py # AssetGroupImagesMixin (the P-MAX image swap, #626:
│   │                    #   existing asset id or local upload, one atomic mutate)
│   ├── _keywords.py     # KeywordsMixin (add/remove/suggest/diagnose)
│   ├── _placements.py   # PlacementsMixin (negative placements: sites/apps/app categories, #544)
│   ├── _analysis.py     # AnalysisMixin aggregator, composing the split modules below
│   ├── _analysis_auction.py / _analysis_btob.py / _analysis_budget.py    # + _analysis_constants.py
│   ├── _analysis_keywords.py / _analysis_performance.py / _analysis_rsa.py / _analysis_search_terms.py
│   ├── _diagnostics.py  # DiagnosticsMixin (campaign diagnosis, one method per delivery check)
│   ├── _extensions.py   # ExtensionsMixin aggregator, composing the split modules below
│   ├── _extensions_callouts.py / _extensions_conversions.py / _extensions_sitelinks.py / _extensions_targeting.py
│   ├── _monitoring.py   # MonitoringMixin (anomaly detection/reporting)
│   ├── _creative.py     # CreativeMixin (LP analysis/message match)
│   ├── _media.py        # MediaMixin (image asset upload)
│   ├── _rsa_validator.py     # RSA ad text validator
│   ├── _rda_validator.py     # RDA input validator (display ads)
│   ├── _message_match.py     # LP screenshot capture (Playwright, SSRF-guarded)
│   ├── _gaql_validator.py    # GAQL input validators (ASCII-only ID/date whitelists — see below)
│   ├── _date_ranges.py       # Period-over-period comparison windows (SDK-free, #809)
│   └── accounts.py           # Accessible-customer / account listing
├── meta_ads/            # Meta Ads API client (Mixin composition)
│   ├── client.py        # MetaAdsApiClient (main entry)
│   ├── mappers.py       # Response mapping
│   ├── _campaigns.py    # CampaignsMixin
│   ├── _ad_sets.py      # AdSetsMixin
│   ├── _ads.py          # AdsMixin
│   ├── _creatives.py    # CreativesMixin (image/carousel/collection/dynamic)
│   ├── _videos.py       # VideosMixin (AdVideo upload/status/thumbnails)
│   ├── _audiences.py    # AudiencesMixin
│   ├── _pixels.py       # PixelsMixin
│   ├── _placement_exclusions.py  # PlacementExclusionsMixin (ad-set publisher exclusions, #544)
│   ├── _insights.py     # InsightsMixin
│   ├── _analysis.py     # AnalysisMixin
│   ├── _catalog.py      # CatalogMixin (product catalogs/feeds)
│   ├── _conversions.py  # ConversionsMixin (Conversions API / CAPI)
│   ├── _hash_utils.py   # SHA-256 PII hashing for CAPI
│   ├── _leads.py        # LeadsMixin (lead forms/leads)
│   ├── _page_posts.py   # PagePostsMixin (page posts/boost, page photos for an Instant Form cover)
│   ├── _instagram.py    # InstagramMixin (accounts/media/boost)
│   ├── _split_test.py   # SplitTestMixin (A/B split tests)
│   ├── _ad_rules.py     # AdRulesMixin (automated rules)
│   ├── _conversion_count.py  # Conversion-count parsing helper
│   ├── _graph_error.py  # Graph error detail formatter (never the raw body)
│   ├── _period.py       # Date-period resolution helper
│   └── accounts.py      # Ad-account listing
├── search_console/      # Google Search Console API client (reuses Google OAuth2 credentials)
│   └── client.py        # SearchConsoleApiClient
├── mcp/                 # MCP server (Google/Meta/Search Console + Rollback/Analysis + Analytics registry, Creative Studio, Learning, mureo Context)
│   ├── server.py                          # MCP Server entry point (stdio-based)
│   ├── _helpers.py                        # Shared handler utilities
│   ├── journal.py                         # Append-only JOURNAL.jsonl of every tool call + outcome (#758)
│   ├── journal_chain.py                   # The append itself (#758 phase 6): rotation, the `prev`/`h`
│   │                                      #   hash chain across files, and verify_chain. A truncated
│   │                                      #   TAIL is the one thing it cannot prove — say so, always
│   ├── _journal_hook.py                   # Dispatcher hook: family/mutating classification, one record per call (#758)
│   ├── _plugin_tables.py                  # Plugin dispatch / semantics dict objects, below server; server fills them in place at load (#879)
│   ├── _server_slots.py                   # Family view / dispatcher / policy evaluator slots, below server; server publishes them at load for the journal, auto-evaluation and rollback hooks (#879)
│   ├── tools_google_ads.py                # Google Ads tool definitions (aggregator)
│   ├── _tools_google_ads_*.py             # Tool definition sub-modules
│   ├── _handlers_google_ads.py            # Google Ads base handlers
│   ├── _google_ads_client.py              # Google Ads client / credential resolution
│   ├── _handlers_google_ads_extensions.py # Extensions handlers
│   ├── _handlers_google_ads_analysis.py   # Analysis handlers
│   ├── tools_meta_ads.py                  # Meta Ads tool definitions (aggregator)
│   ├── _tools_meta_ads_*.py               # Tool definition sub-modules
│   ├── _handlers_meta_ads.py              # Meta Ads base handlers
│   ├── _meta_ads_client.py                # Meta Ads client / credential resolution
│   ├── _handlers_meta_ads_extended.py     # Extended handlers
│   ├── _handlers_meta_ads_other.py        # Other handlers
│   ├── _handlers_meta_ads_creatives.py    # Upload / creative handlers
│   ├── tools_search_console.py            # Search Console tool definitions
│   ├── _handlers_search_console.py        # Search Console handlers
│   ├── tools_rollback.py                  # rollback_plan_get / rollback_apply
│   ├── _handlers_rollback.py              # Rollback handlers (dispatcher read from _server_slots)
│   ├── tools_batch.py                     # mureo_batch_begin / _end / _status (#549)
│   ├── _handlers_batch.py                 # Batch lifecycle handlers
│   ├── tools_change_import.py             # mureo_external_changes_import (#545)
│   ├── _handlers_change_import.py         # Change-import handler
│   ├── tools_analysis.py                  # analysis_anomalies_check / analysis_delivery_collapse_* / analysis_exclusion_impact_preview
│   ├── _handlers_analysis.py              # Anomaly detector composition handler
│   ├── _handlers_delivery_collapse.py     # Delivery-collapse check / diagnose handlers (#546)
│   ├── _handlers_exclusion_impact.py      # analysis_exclusion_impact_preview handler (#547)
│   ├── exclusion_preflight.py             # Pre-dispatch exclusion sizing + refusal + notice (#547)
│   ├── exclusion_sources.py               # Built-in per-platform delivery sources for the above (#547)
│   ├── tools_analytics_registry.py        # mureo_analytics_modules_list / mureo_analytics_run (#440)
│   ├── tools_creative_studio.py           # creative_studio_* (visual generation + compose)
│   ├── tools_learning.py                  # mureo_learning_insights_get / mureo_consult_advisor
│   ├── tools_learning_preflight.py        # mureo_learning_reset_preflight (#548)
│   ├── tools_mureo_context.py             # STRATEGY.md / STATE.json + mureo_outcome_evaluate tools
│   ├── _handlers_mureo_context.py         # Context (STRATEGY/STATE) handlers
│   ├── _campaign_schema.py                # mureo_state_upsert_campaign's `campaign` argument schema
│   ├── _handlers_outcome.py               # mureo_outcome_evaluate: the evaluation
│   │                                      #   table, by evaluation_of or by maps (#889)
│   ├── _tools_decisions.py                # mureo_decision_record (#758 phase 3) — a PARTIAL of the
│   │                                      #   mureo_context family, not a family of its own, so
│   │                                      #   server.py and the journal labels are untouched
│   ├── _auto_evaluation_hook.py           # mureo_state_get's automatic observation closure
│   │                                      #   (#758 phase 5): the `auto_evaluate` switch, the
│   │                                      #   policy-gate check that decides whether the write
│   │                                      #   may happen, and the response fragment
│   ├── _outcome_thresholds.py             # The outcome verdict's noise band + volume floors from
│   │                                      #   STRATEGY.md ## Guardrails (#888), read here so
│   │                                      #   analysis / context gain no edge onto the gate
│   ├── _tools_settings.py                 # mureo_state_settings_set (#848) — workspace settings,
│   │                                      #   another PARTIAL of the mureo_context family
│   ├── _ops_history_param.py              # `expected` / `decision_id` on mutating tools (#848):
│   │                                      #   schema injection, the requirement check, the binding
│   ├── _ops_history_records.py            # The same requirement on the two recording tools
│   ├── _history_ops.py                    # The `decisions` / `ops_history` history sources
│   ├── _handlers_decisions.py             # Its handler: mints the id + timestamp, appends
│   ├── _tools_history.py                  # mureo_history_query (#758 phase 4b) — another PARTIAL
│   │                                      #   of the mureo_context family; READ-ONLY, so the
│   │                                      #   dispatcher injects no `reason` into its schema
│   ├── _handlers_history.py               # Its handler: the filters, and one bounded section per
│   │                                      #   source (action_log / journal / daily / reports /
│   │                                      #   decisions / ops_history)
│   ├── journal_read.py                    # Reading JOURNAL.jsonl back — the parse, the date, the
│   │                                      #   filter and the tail reader, shared with `mureo journal`.
│   │                                      #   The *_files forms walk the rotated siblings as one record
│   └── tool_provider.py                   # Third-party plugin → MCP tool exposure layer (#89)
├── cli/                 # Typer CLI (setup + auth + configure + BYOD + providers + rollback + repair + journal; ad ops are via MCP)
│   ├── main.py          # CLI entry point (`mureo` command)
│   ├── setup_cmd.py     # `mureo setup claude-code` / `cursor` / `codex` / `gemini`
│   ├── setup_codex.py   # Codex install-kit: MCP, credential guard, operational + foundation skills
│   ├── _skill_paths.py  # get_data_path / replace_dest, shared by the skill installers (#879)
│   ├── setup_gemini.py  # Gemini extension manifest at ~/.gemini/extensions/mureo/
│   ├── native_skills.py # Deploy/remove plugin native slash skills via `mureo.native_skills` group (#439)
│   ├── configure_cmd.py # `mureo configure` — open the local web configuration UI
│   ├── byod_cmd.py      # `mureo byod import` / `status` / `remove` / `clear`
│   ├── providers_cmd.py # `mureo providers list` / `install` / `uninstall` (official MCP catalog)
│   ├── service_cmd.py   # `mureo service` — install/restart the auto-start configure daemon (#241)
│   ├── upgrade_cmd.py   # `mureo upgrade` — pipx venv-aware bulk upgrade of mureo + plugins
│   ├── skill_status.py  # Deployed workflow skills current / stale / missing (#728); read by
│   │                    #   upgrade_cmd and web/status_collector.py (#863)
│   ├── auth_cmd.py      # `mureo auth setup` / `status` / `check-*` / `upgrade-google`
│   ├── rollback_cmd.py  # `mureo rollback list` / `show` (inspection only; apply routes through MCP)
│   ├── journal_cmd.py   # `mureo journal` — read back JOURNAL.jsonl (last/tool/since/failures/mutations/json/all)
│   ├── _journal_verify.py # `mureo journal --verify` — the hash chain over the live file and its
│   │                    #   rotated siblings; exit 0 ok / 1 broken / 2 no journal (#758 phase 6)
│   ├── repair_cmd.py    # `mureo repair platform-key` — drop a platforms entry the DOCUMENT
│   │                    #   shows to be wrong (duplicate of a resolvable key, or empty stub);
│   │                    #   dry run by default, backs up first (#610/#616).
│   │                    #   `--all` sweeps every client, summary first, one prompt (#614).
│   │                    #   `--key <k> --drop-duplicate` records the operator's OWN decision
│   │                    #   about a duplicate whose two keys both resolve (#636); not with --all
│   ├── _repair_preview.py # What that command prints — the half that has to be TRUE: why an
│   │                    #   entry can go, what is NOT changed (scoped to the plan), every
│   │                    #   same-account sibling, and conversion_action_types (#616/#617/#618).
│   │                    #   Also the not_collected note an entry carries (#643) and both
│   │                    #   entries of a duplicate mureo hands back to decide (#645)
│   ├── _repair_clients.py # Which STATE.json files `--all` sweeps — reuses the Reports tab's
│   │                    #   optional list_clients / state_store_for_client seam, never a second one
│   ├── _state_file.py   # The shared `--state-file` option + workspace default (rollback + repair)
│   └── _tty.py          # TTY-safe helpers for non-interactive setup + terminal_safe() scrubbing
├── context/             # File-based strategy context (no DB)
│   ├── strategy.py      # STRATEGY.md parser/writer
│   ├── state.py         # STATE.json parser/writer — the single import site; since #917 a facade that
│   │                    #   re-exports state_io (read / atomic write / lock / clock), campaign_store
│   │                    #   (upsert_campaign, _platform_base, _stamp_fetched_at), action_log_store
│   │                    #   (append_action_log), batch_store (begin/end_batch), platform_store (rollups,
│   │                    #   daily, not_collected, conversion override) and report_store (set_report, set_display).
│   │                    #   Two more stores sit beside them and are NOT re-exported (#879): decision_store
│   │                    #   (append_decision) and settings_store (set_ops_history_enabled)
│   ├── models.py        # StrategyEntry, StateDocument, CampaignSnapshot, BatchRecord; re-exports ActionLogEntry
│   ├── action_log_entry.py # ActionLogEntry (rollback_of, batch_id) + EXTERNAL_ORIGIN (#851), re-exported by models.py
│   ├── display_models.py # The display contract models (#706), re-exported by models.py
│   ├── batch.py         # Batch id minting + the action_log stamping rule (#549)
│   ├── decisions.py     # The `decisions` section (#758 phase 3): DecisionRecord, its
│   │                    #   bounds, the cross-reference checks and the pure queries (the
│   │                    #   writer is decision_store.append_decision, #879). Append-only
│   │                    #   — an adoption is a NEW record naming the first in
│   │                    #   `supersedes`, never an edit — because `display.proposals`
│   │                    #   is replaced whole on every dashboard write and so cannot
│   │                    #   be a history
│   ├── _decision_codec.py # That section's two codec halves. Strict like action_log's,
│   │                    #   not tolerant like batches': a decision record IS history
│   ├── expected.py, approval.py, iso_time.py, settings.py, decision_chain.py, ops_intent.py,
│   │   ops_linkage.py, ops_fields.py, ops_history_view.py,
│   │   ops_history_duplicates.py, ops_history_query.py
│   │                    #   Ops history (#848): expected effects, the settings section,
│   │                    #   linking entries to decisions inside the locked append, the
│   │                    #   derived decision → actions → evaluation view, the
│   │                    #   possible-duplicate hint between two decisions (#851), and the
│   │                    #   date-bounded read both MCP and the dashboard use. See
│   │                    #   docs/ops-history.md
│   ├── daily.py         # The day-grain history write MINUS the file (#690/#710):
│   │                    #   `with_platform_daily(doc, ...)` (guard + per-date merge +
│   │                    #   trim) and `capped_platform_daily(daily)` (the 35-day
│   │                    #   retention rule on its own), for a writer that lands
│   │                    #   `daily` inside its OWN atomic document write.
│   │                    #   `set_platform_daily` is the path-based wrapper, so both
│   │                    #   routes keep one set of semantics. Optional `as_of_date`
│   │                    #   = today in the ACCOUNT's timezone: an account closes its
│   │                    #   day locally, so the host clock refused a complete
│   │                    #   yesterday-JST on a UTC host every night
│   ├── observations.py  # What closes an action_log observation, and which are
│   │                    #   past due (#651). ONE rule for `mureo_state_get(
│   │                    #   action_log="pending")` and the Reports triage count;
│   │                    #   two entry shapes (rendered dict / ActionLogEntry)
│   ├── auto_evaluation.py # Closing a past-due observation mureo can decide on
│   │                    #   its own (#758 phase 5): the rule order, the fixed
│   │                    #   skip vocabulary, and ONE locked write for the whole
│   │                    #   pass. Pure decision + write, no MCP imports
│   ├── action_baseline.py # The baseline an action_log append is missing
│   │                    #   (#890): the campaign snapshot's metrics, whole,
│   │                    #   plus `days`; refused when stale. Pure
│   ├── actor_stamp.py   # `stamp_actor` — the WHO/WHY stamp shared by
│   │                    #   `append_action_log` and the automatic closure, so a
│   │                    #   mureo-written entry carries the same identity on both;
│   │                    #   its identity half `stamp_identity` also stamps decisions
│   ├── monthly_budget.py # The `## Custom: Monthly Budget` reader (#652) — the operator's
│   │                     #   INTENDED monthly spend, twin of `guardrails_from_strategy_text`.
│   │                     #   Separate type from `Guardrails` on purpose: a target is not a
│   │                     #   ceiling. "Not set" is a first-class answer (never 0), a total
│   │                     #   derived from `max_total_daily_budget` says so, and the figure
│   │                     #   stays in STRATEGY.md — never copied into STATE.json
│   ├── platform_monthly_budget.py # Rung 2 of that precedence (#656): the sum of the
│   │                     #   per-campaign monthly budgets, for platforms that DECLARED they
│   │                     #   have the concept (`register_monthly_budget_support`; core
│   │                     #   declares none). A third `source`, so a configured sum is never
│   │                     #   shown as an agreed target. No total is stored — it is computed
│   │                     #   on read — and an incomplete or uncollected campaign set is
│   │                     #   withheld rather than summed short, reported as
│   │                     #   `IncompletePlatform(platform, reason)` so an operator is told
│   │                     #   WHICH gap it is (a wrong declaration, a sync behind, a failed
│   │                     #   collection) instead of reading a debug log. The two splits are
│   │                     #   separate fields — `per_platform` is only ever what the operator
│   │                     #   wrote — and the type refuses a split that does not match `source`
│   ├── monthly_budget_models.py # `MonthlyBudget` + the `SOURCE_*` vocabulary (#879), below
│   │                     #   both readers above so each imports it at top level;
│   │                     #   `monthly_budget.py` re-exports them. No runtime mureo imports
│   ├── platform_guards.py # Write-time platform-key guards (#534/#609) — the one "is this key
│   │                      #   real?" answer every other surface asks, including the read-side
│   │                      #   label path (`installed_platform_names`, shared since #631)
│   ├── platform_repair.py # The repair half (#610): plan + drop an entry under an unresolvable
│   │                      #   key. Unresolvable is the FILTER, not the criterion — the document
│   │                      #   must show the entry wrong (#616), and conversion_action_types is
│   │                      #   never dropped (#617). `drop_duplicates` is the one widening: a key
│   │                      #   the OPERATOR named, dropped only against a same-account sibling
│   │                      #   (#636). Drops, never merges; backs up; no action_log
│   ├── action_log_guard.py # A whole-document write that shortens or rewrites action_log leaves a
│   │                      #   timestamped backup and a WARNING first (#758 phase 6). Advisory like
│   │                      #   warn_on_duplicate_accounts — but a backup that FAILS stops the write
│   └── errors.py        # Context-specific errors
├── analysis/            # Analysis utilities
│   ├── lp_analyzer.py   # Landing page analyzer
│   ├── anomaly_detector.py  # Zero-spend / CPA-spike / CTR-drop detection (pure, sample-size-gated)
│   ├── outcome_note.py  # An evaluation row's note read back into parts (#894), from the
│   │                    #   NOTE_* templates outcome_eval / expected_eval write with.
│   │                    #   Imports them; nothing imports it back except the served view
│   ├── delivery_collapse.py # Delivery-collapse detection (#546) — weekday-aware baseline taken
│   │                        #   from the platform's own daily delivery, never from action_log
│   ├── delivery_collapse_config.py  # ## Guardrails -> CollapseThresholds (the only I/O half)
│   ├── collapse_diagnosis.py        # Change x metric timeline + elimination ladder; reports the
│   │                                #   open questions, never a cause it did not evidence
│   ├── exclusion_impact/    # Delivery-impact preview for bulk exclusions (#547; pure)
│   │   ├── models.py        # DeliveryRecord / ExclusionTarget / ExclusionImpact + coverage verdicts
│   │   ├── matching.py      # Per-entity-kind match rules (host, app id, negative keyword match type)
│   │   ├── estimator.py     # The share itself — incremental and cumulative
│   │   ├── rules.py         # ## Guardrails exclusion keys, the one refusal decision, inert-rule reporting
│   │   └── surfaces.py      # Which tools are exclusion surfaces (mureo's + plugin-registered)
│   └── tracking/        # Tracking-parameter consistency: platform-neutral detector + per-platform URL accessors (#550)
├── rollback/            # Rollback feature (allow-list gated, append-only audit trail)
│   ├── models.py        # RollbackStatus / RollbackPlan + batch verdicts (BatchCoverage, BatchRollbackPlan)
│   ├── planner.py       # plan_rollback(ActionLogEntry) -> RollbackPlan | None
│   ├── batch.py         # plan_batch_rollback(doc, batch_id) -> every member, gaps included (#549)
│   └── executor.py      # execute_rollback(...) -> appends ActionLogEntry(rollback_of=index)
├── adapters/            # Provider adapters wrapping each ad-platform client as a registry Protocol
├── analytics/           # Analytics-module registry for external MCP / plugin platforms (#120)
│                        #   (builtin/ builds the native modules, `builtin_analytics_modules()`;
│                        #   the registry imports it and registers them — builtin never imports
│                        #   the registry, #879)
├── change_import/       # Import changes made OUTSIDE mureo into action_log (#545)
│   ├── models.py        # ExternalChange / ChangeFeedResult / ChangeImportOutcome
│   ├── protocol.py      # ChangeFeedProvider Protocol (`mureo.change_feeds` entry-point group)
│   ├── registry.py      # Discovery + per-plugin fault isolation + lookup; registers
│   │                    #   `builtin_change_feeds()` on first use (builtin/ never imports it, #879)
│   ├── dedupe.py        # Already-imported vs mureo's own change (why one is approximate)
│   ├── importer.py      # Watermark, action_log write, per-platform outcome
│   └── builtin/         # Native feeds — google_ads only today; see docs/change-import.md
├── core/                # Extension Protocols + file-backed impls + RuntimeContext; provider & skill discovery
│   ├── tool_failure.py  # The MCP failure-envelope READER (failure_of / is_error_result, #863); the
│   │                    #   writer stays in mcp/_helpers.py, which re-exports these names.
│   │                    #   exception_text: the one exception-to-text formatter (#603, #865)
│   ├── credential_source.py # Per-call credential-source ABI (#821); mcp/credential_source.py
│   │                    #   re-exports the same objects (#863)
│   ├── platform_keys.py # Platform-key vocabulary; also defines ANALYTICS_ENTRY_POINT_GROUP, which
│   │                    #   context/platform_guards.py reads without importing analytics (#879)
│   ├── account_scope.py # Workspace account scoping (#411): resolve_customer_id / resolve_account_id,
│   │                    #   called by every platform client opener before it builds a client
│   ├── ga4_adc.py       # The authorized_user ADC file + `ga4` section derived from a Google
│   │                    #   authorization granted analytics.readonly (#938), as <store name>-ga4-adc.json
│   │                    #   beside the store; never over a section naming another file
│   ├── google_scopes.py # The Google OAuth scopes and the granted-set check (no Google Ads
│   │                    #   scope fails the setup; Search Console / GA4 are optional)
│   └── rotation.py      # Size-bounded append files (#758 phase 6): the one MUREO_JOURNAL_MAX_BYTES
│                        #   reader, the rotated name, and sibling_files (the whole record, oldest first)
├── providers/           # Official MCP provider catalog + one-command install helpers (#86)
│                        #   credential_env.py: the credential env a provider reads and the
│                        #   native-disable gate, shared by `mureo providers add` and the
│                        #   configure UI install route (#945); holds the env-var allow-list
│                        #   that web/env_var_writer.py imports back (providers -> core only)
├── policy/              # Built-in policy gates (strategy_gate) — ship with OSS, run by default
│   ├── guardrails.py         # The `## Guardrails` parser (Guardrails, parse_guardrails,
│   │                         #   guardrails_from_entries); imports nothing from context, so
│   │                         #   context/monthly_budget.py reads it at top level.
│   │                         #   strategy_gate re-exports its public names (#879)
│   ├── learning_rules.py     # Per-platform learning-period facts + their first-party sources (#548)
│   ├── platform_model.py     # Per-platform delivery-model prose + first-party sources (#648) —
│   │                         #   the ONLY always-on plugin channel: rendered into the MCP
│   │                         #   server's `instructions`, scoped to the platforms served
│   └── learning_reset.py     # Reset-class + learning-state pre-flight the gate refuses on
├── learning/            # Read-side /learn companion: insight federation across configured sources
├── creative_studio/     # Creator-grade ad-creative (image) generation via pluggable providers
│                        #   (providers/_base.py: ImageProvider protocol + shared helpers, below the
│                        #   registry in providers/__init__.py and the built-in providers, #879)
├── byod/                # Bring Your Own Data — CSV-backed offline analysis (see BYOD Mode below)
├── web/                 # Local `mureo configure` UI — stdlib http.server on 127.0.0.1 (no web framework)
│   │                    #   web imports cli helper modules by public names; cli imports only the
│   │                    #   web leaves (instance, service, report_clients) and configure_cmd;
│   │                    #   no web submodule loads server.py unless it needs it (#863)
│   ├── action_result.py # ActionResult + send_result: the one failure mapping of action routes (#864);
│   │                    #   send_steps: multi-step envelope, 500 + "steps" only when every step failed (#891)
│   ├── static_assets.py # The files GET /static/<name> may serve (_STATIC_ALLOWLIST) and their
│   │                    #   content types; add a new mureo/_data/web/ file here
│   ├── _extension_contributions.py # StaticAsset / ViewContribution / DashboardCard, re-exported by extensions.py
│   └── auth_wizard.py   # Browser-based OAuth wizard spawned by web/oauth_bridge.py (per-platform creds)
├── demo/                # `mureo demo init` synthetic-bundle bootstrap (round-trips through BYOD)
├── auth.py              # Credentials management (~/.mureo/credentials.json + env vars + Meta token auto-refresh)
├── auth_setup.py        # Interactive setup wizard (browser OAuth flow)
├── credential_guard.py  # Blocks AI agents from reading ~/.mureo/credentials.json
├── _credential_guard/   # The hook payloads' pieces, one module per step (reasons,
│                        #   path_guard, chars, quoting, spans, braces, bash_guard);
│                        #   import from credential_guard.py
├── logging_setup.py     # Configure-server logging (#581): rotating ~/.mureo/logs/configure.log,
│                        #   MUREO_LOG_LEVEL, and the HTTP access-log query scrubber. Installed by
│                        #   the configure entry point only — never at import time, never on root.
├── client_factory.py    # Per-platform BYOD-vs-live client factory (MCP handlers, analytics,
│                        #   change import); below mcp, so none of them loads the server (#863);
│                        #   open_google_ads_client / open_meta_ads_client are the one
│                        #   Google Ads / Meta Ads openers (#875)
├── installed_packages.py
│                        # The installed mureo / mureo-* distributions, for `mureo upgrade`
│                        #   and the dashboard's About / update check / upgrade (#863)
└── throttle.py          # Rate limiting (token bucket + rolling hourly cap)

skills/                       # Native slash skills — one `<name>/SKILL.md` per skill (migrated from
│                             #   .claude/commands in #439; invocable as `/<name>`). Plugins contribute
│                             #   more via the `mureo.native_skills` entry-point group, deployed/removed
│                             #   by `mureo/cli/native_skills.py`.
├── onboard/, daily-check/, rescue/, budget-rebalance/, …   # ~20 operational (workflow) skills
│   └── SKILL.md
└── _mureo-*/                 # 9 foundation skills: _mureo-shared / _mureo-state-schema /
    └── SKILL.md              #   _mureo-report-persist / _mureo-strategy / _mureo-google-ads / _mureo-meta-ads /
                              #   _mureo-amazon-ads / _mureo-learning / _mureo-pro-diagnosis
docs/integrations.md          # Platform discovery + external MCP integration guide

The skill tree ships twice. skills/ is canonical; mureo/_data/skills/ is the copy the wheel installs. Edit both, always — every skill present in either tree is byte-compared, whole directory against whole directory, by tests/test_plugin_manifests.py::test_skill_copies_are_byte_identical. The one exemption is _mureo-pro-diagnosis (canonical only, because /learn scaffolds the operator's own copy under ~/.claude/skills/); it is declared in _CANONICAL_ONLY_SKILLS with its reason, not hidden behind a name filter.

MCP Tools

Google Ads

CategoryTools
Campaignscampaigns.list, campaigns.get, campaigns.create, campaigns.update, campaigns.update_status, campaigns.diagnose
Ad Groupsad_groups.list, ad_groups.create, ad_groups.update
Adsads.list, ads.create, ads.create_display, ads.update, ads.update_status, ads.policy_details
Keywordskeywords.list, keywords.add, keywords.remove, keywords.suggest, keywords.diagnose, keywords.pause, keywords.audit, keywords.cross_adgroup_duplicates
Negative Keywordsnegative_keywords.list, negative_keywords.add, negative_keywords.remove, negative_keywords.add_to_ad_group, negative_keywords.suggest
Negative Placementsnegative_placements.list, negative_placements.add, negative_placements.remove
Placement Performance(read used by analysis_exclusion_impact_preview: group_placement_view via get_placement_performance)
Budgetbudget.get, budget.update, budget.create
Accountsaccounts.list
Search Termssearch_terms.report, search_terms.analyze
Sitelinkssitelinks.list, sitelinks.create, sitelinks.remove
Calloutscallouts.list, callouts.create, callouts.remove
Conversionsconversions.list, conversions.get, conversions.performance, conversions.create, conversions.update, conversions.remove, conversions.tag
Targetingrecommendations.list, recommendations.apply, device_targeting.get, device_targeting.set, bid_adjustments.get, bid_adjustments.update, location_targeting.list, location_targeting.update, schedule_targeting.list, schedule_targeting.update, change_history.list, demographic_targeting.list, audience_targeting.list
Analysisperformance.report, performance.analyze, cost_increase.investigate, health_check.all, ad_performance.compare, ad_performance.report, network_performance.report, budget.efficiency, budget.reallocation, auction_insights.get, rsa_assets.analyze, rsa_assets.audit, search_terms.review
B2Bbtob.optimizations
Creativelanding_page.analyze, creative.research
Monitoringmonitoring.delivery_goal, monitoring.cpa_goal, monitoring.cv_goal, monitoring.zero_conversions
Capturecapture.screenshot
Devicedevice.analyze
CPCcpc.detect_trend
Assetsassets.upload_image, image_assets.list
Performance Max (#590, #626)asset_group_assets.list (text + images in one read), asset_group_assets.replace (text), asset_group_images.replace (images, from an asset id or a local file) — P-MAX creative lives on asset_group_asset, not ad_group_ad

Meta Ads

CategoryTools
Campaignscampaigns.list, campaigns.get, campaigns.create, campaigns.update, campaigns.pause, campaigns.enable
Ad Setsad_sets.list, ad_sets.create, ad_sets.update, ad_sets.get, ad_sets.pause, ad_sets.enable
Adsads.list, ads.create, ads.update, ads.get, ads.pause, ads.enable
Creativescreatives.create_carousel, creatives.create_collection, creatives.list, creatives.create, creatives.create_dynamic, creatives.upload_image
Imagesimages.upload_file
Insightsinsights.report, insights.breakdown
Audiencesaudiences.list, audiences.create, audiences.get, audiences.delete, audiences.create_lookalike
Targetingtargeting.search, targeting.categories
Conversions APIconversions.send, conversions.send_purchase, conversions.send_lead
Pixelspixels.list, pixels.get, pixels.stats, pixels.events, pixels.create
Analysisanalysis.performance, analysis.audience, analysis.placements, analysis.cost, analysis.compare_ads, analysis.suggest_creative
Placement Exclusionsexcluded_placements.get, excluded_placements.set
Product Catalogcatalogs.list, catalogs.create, catalogs.get, catalogs.delete, products.list, products.add, products.get, products.update, products.delete, feeds.list, feeds.create
Lead Adslead_forms.list, lead_forms.get, lead_forms.create, leads.get, leads.get_by_ad
Videosvideos.upload, videos.upload_file
Split Testssplit_tests.list, split_tests.get, split_tests.create, split_tests.end
Ad Rulesad_rules.list, ad_rules.get, ad_rules.create, ad_rules.update, ad_rules.delete
Pagespages.list
Page Postspage_posts.list, page_posts.boost
Instagraminstagram.accounts, instagram.media, instagram.boost

Search Console

CategoryTools
Sitessites.list, sites.get
Analyticsanalytics.query, analytics.top_queries, analytics.top_pages, analytics.device_breakdown, analytics.compare_periods
Sitemapssitemaps.list, sitemaps.submit
URL Inspectionurl_inspection.inspect

mureo Core Tools (platform-independent)

These families are not tied to a single ad platform. Tool names are the exact MCP tool ids.

FamilyTools
Analytics Registry (#440)mureo_analytics_modules_list, mureo_analytics_run
Rollbackrollback_plan_get, rollback_apply
Batch (#549)mureo_batch_begin, mureo_batch_end, mureo_batch_status
Change Import (#545)mureo_external_changes_import
Analysisanalysis_anomalies_check, analysis_delivery_collapse_check, analysis_delivery_collapse_diagnose, analysis_exclusion_impact_preview
Creative Studiocreative_studio_providers_list, creative_studio_generate_visual, creative_studio_edit_visual, creative_studio_compose, creative_studio_brand_kit_get
Learningmureo_learning_insights_get, mureo_consult_advisor
Learning pre-flight (#548)mureo_learning_reset_preflight
mureo Contextmureo_strategy_get, mureo_strategy_set, mureo_state_get, mureo_state_action_log_append, mureo_state_upsert_campaign, mureo_state_report_set, mureo_state_display_set, mureo_state_platform_metrics_set, mureo_state_platform_daily_set, mureo_state_platform_not_collected_set, mureo_state_workspace_not_collected_set, mureo_state_set_conversion_events, mureo_outcome_evaluate, mureo_decision_record, mureo_history_query, mureo_state_settings_set

Design Constraints

  • Strategy-driven — all operations are guided by STRATEGY.md context.
  • Workflow-first — slash commands orchestrate multi-step operations.
  • Platform-agnostic — designed to work with official platform MCPs as they become available.
  • No database dependencies — no SQLAlchemy, no ORM. File-based context only (STRATEGY.md / STATE.json).
  • No LLM dependencies — no OpenAI SDK, no Anthropic SDK. Tools return structured data for agents to interpret.
  • No web framework dependencies — no FastAPI, no Flask. CLI (Typer) and MCP (stdio) only.
  • Tools return structured JSON data only — no formatted text, no Markdown in tool responses.
  • All data models use frozen dataclasses — immutable by default.
  • Credentials via file or env vars — ~/.mureo/credentials.json with environment variable fallback.
  • Built-in rate limiting — token bucket throttling per platform prevents API bans from high-speed agent requests (Google Ads: 10 QPS, Meta Ads: 20 QPS + 50K/hr cap, Search Console: 5 QPS, Creative Studio: 1 QPS + 120/hr cap, Plugin tools: 5 QPS). See mureo/throttle.py.
  • Meta token auto-refresh — Long-Lived Tokens are automatically refreshed when 53+ days old (requires app_id and app_secret). See mureo/auth.py.

Coding Standards

  • Python 3.10+
  • PEP 8 compliance
  • Type annotations on all function signatures
  • frozen=True on all dataclasses
  • File size limit: 800 lines
  • Function size limit: 50 lines
  • Formatter: black (line-length 88)
  • Linter: ruff (select: E, F, I, N, W, UP, B, A, SIM, TCH, PLC0415, BLE)
  • Type checker: mypy (strict mode)

Consistency Rules

Structure drifts before logic breaks: a second error envelope, a helper that duplicates another, a new name for an existing concept, a function-local import added to dodge a cycle. The 2026-10 structural audit found all four here (#862–#866). These rules exist so that a change decided by one person and written by another — or by an agent — keeps the shape the codebase already has.

  • One failure shape per boundary. MCP tools fail through the helpers mureo/mcp/_helpers.py provides (api_error_handler, the API error: envelope, the auth_error result, _json_result, the refused / error envelope of _failure_result), read back by failure_of in mureo/core/tool_failure.py (the reader, re-exported by _helpers; the writer stays in _helpers because it builds MCP TextContent, #863); web handlers fail through send_error_json (mureo/web/_helpers.py; send_failure picks 400 or 500 from the caller's fault codes), and web actions return ActionResult (mureo/web/action_result.py: refused = 400, failed = 500) sent by send_result; the contract is "Failure contract" in docs/plugin-authoring.md. Do not add a new error shape, status word or {"error": ...} variant. If the existing helper cannot express a case, extend the helper in one place and update failure_of (which is_error_result and the journal read) with it (#862, #864).
  • Reuse before writing. Before adding a helper, search for one with the same purpose — not only the same name — and diff the bodies. A byte-identical or near-identical copy is a defect; mixin type stubs that resolve to one implementation are not.
  • No function-local imports to break an import cycle. Fix the dependency direction instead. If one is unavoidable, comment the reason on the import and open an issue (#863). Startup-cost deferrals stay documented as such. Ruff enforces this (PLC0415): a function-local import under mureo/ carries # noqa: PLC0415 — <reason> with exactly one of startup cost (#NNN) (a heavy import kept off a hot entry point, with a # Startup cost (~X CPU): ... line above it), optional dependency (not a required dependency; the ImportError is handled at the site), import cycle: mureo.<a> -> mureo.<b> (#879) (a real load-time cycle not yet removed; <b> is the module the statement imports, and #879 is the cycle inventory) or PEP 562 re-export (#863). tests/test_plc0415_reasons.py runs ruff with --ignore-noqa and checks every site it finds; a file-level # ruff: noqa that covers the rule is refused. No per-file ignore under mureo/ may list the rule: the tests/** entry is the only place in the ruff config that switches it off.
  • One name per concept. Check the existing vocabulary (period, customer_id, account_id, platform, …) before introducing a parameter, field or key name. Date ranges are three concepts: period (a reporting window handed to the platform, a constant or YYYY-MM-DD..YYYY-MM-DD), start_date / end_date (two explicit dates for an API without constants), since / until (bounds on mureo's own records) — see docs/mcp-server.md "Date ranges".
  • No silent broad excepts. Narrow the type where the failing call and its exception types are known. Otherwise except Exception carries # noqa: BLE001 — <reason> (at least two words, not a placeholder such as reason or todo), and a broad except that continues logs at WARNING. Ruff enforces this (BLE, for mureo/ and tests/ alike): tests/test_ble001_reasons.py runs ruff with --ignore-noqa over both trees (tests are not exempt) and checks every site it finds; a file-level # ruff: noqa that covers the rule is refused, and no ignore or per-file ignore may list it (#865). A failed partial read inside a composite result sets its key to None and logs Failed to retrieve <what> (<ExceptionClass>) at WARNING: no stand-in strings, zeros or empty containers. The credential-holding trees and mureo/web log the exception class, never exc_info= or logger.exception() (tests/test_log_exc_info_sweep.py; the existing mureo/web sites sit in an allow-list that only shrinks, #900).
  • Docs move with the code. docs/architecture.md, AGENTS.md, skill files and CHANGELOG.md are updated in the same PR as the change they describe.
  • No provenance in shipped text. Comments, docstrings, test names, docs and CHANGELOG entries explain why the code has its shape; they never record how the change came about: who asked, decided, approved or rejected it, review findings and their labels, workflow roles or phases, internal documents and plan labels. A public issue or PR number is the only attribution. scripts/check_comment_provenance.py enforces it in CI (same scan set as the English-only check).
  • Review the structure, not only the logic. A review rejects, or asks for a justification of, any new helper, new failure shape, new lazy import or new name that the diff introduces. "Tests green" does not settle it.
  • Limits belong in lint. The 50-line function and 800-line file limits above are rules for lint to enforce (tracked with #865), not for reviewers to remember. tests/test_function_length.py enforces both (#917): a function or file under mureo/ over its limit fails unless it is on that test's frozen KNOWN_OVER_LIMIT / KNOWN_LONG_FILES list, a listed one may not grow, and an entry back within the limit has to be removed. The function limit covers the bundled web JS (mureo/_data/web/*.js) as well, through KNOWN_OVER_LIMIT_JS under the same shrink-only rule. The lists only shrink; never add to them.

The same checklist is re-run as a structural audit after each major release, scored A–D per dimension with path:line evidence.

Commit Workflow

Before EVERY code commit, run a code-review agent — no exceptions, including fixup and review-response commits.

  1. After git add, before git commit, invoke python-reviewer (or the language-appropriate code-reviewer) via the Agent tool.
  2. For changes touching security boundaries (auth, OAuth, input validation, FS I/O of external input), also run security-reviewer in parallel.
  3. If the reviewer reports HIGH or CRITICAL findings, fix them and re-run the review before committing — fixes can introduce new issues.
  4. Only commit after the review is clean.

A previous review on the same branch does NOT exempt a follow-up commit. Each commit needs its own review pass — fixup commits, lint cleanups, and review-response commits all qualify.

Exceptions (visual review is sufficient):

  • docs/, README*, or CHANGELOG.md only
  • pyproject.toml version bump only
  • Dependabot GitHub-Actions version bumps
  • Pure typo fixes in comments / docstrings (no logic change)

Not exceptions:

  • Any line of production code logic, even one line
  • Any test file change (test correctness is reviewed too)
  • Pure import-ordering edits

This rule was reinforced after PR #20 (2026-04-19, OAuth helper extraction — 6 issues including 2 HIGH found post-hoc) and PR #75 (2026-05-01, fixup commit pushed without re-review). "Tests green" and "lint clean" are NOT substitutes for code review.

Test Coverage

  • Target: 80% minimum (enforced by tool.coverage.report.fail_under)
  • Mutation check for the delivery-collapse detector: python scripts/mutation_check.py injects ~20 plausible wrong implementations and asserts the tests object to each. Green tests prove the code passes its tests, not that the tests would notice if the code were wrong — and for a detector whose failure mode is silence on a dead account, "the suite is green" is exactly the reassurance that hides the bug. Two CRITICALs in #546 shipped past a green suite. Run it when touching mureo/analysis/delivery_collapse*.py, collapse_diagnosis.py, or any get_daily_delivery_report. A survivor is a gap in the tests, not a pass: add the missing assertion rather than deleting the mutation. tests/test_mutation_harness.py asserts every anchor still resolves, so a refactor cannot turn the harness into a no-op that always "passes".
  • Framework: pytest + pytest-asyncio
  • Run the suite in a clean virtualenv (mureo + [dev], nothing else). The root tests/conftest.py also points the home directory at a temp dir and hides any installed mureo.runtime_context_factory entry point for every test, so a credentials writer called without a path cannot reach the real ~/.mureo/credentials.json or a plugin's shared store (#739)
  • All external API calls (Google Ads, Meta Ads) must be mocked in tests
  • Use @pytest.mark.unit / @pytest.mark.integration for categorization
  • Async test mode: auto (asyncio_mode = "auto")
  • Browser assets (mureo/_data/web/): they ship as plain <script>-loaded files, so nothing there may grow a bundler, a module system or a build step. Rendering is guarded by the substring pins in tests/test_web_assets_*.py. DOM-free logic lives in its own asset next to dashboard.js, one global per file:

    • reports_logic.js → window.MUREO_REPORTS_LOGIC — the money-safety decisions (KPI withholding, freshness aggregation, conflict routing).
    • reports_format.js → window.MUREO_REPORTS_FORMAT — the display vocabulary (flag labels and severities, param detail, numbers, periods, and which of a stored report's fields are headline figures).
    • outcome_text.js → window.MUREO_OUTCOME_TEXT — an outcome evaluation in words (#894): the verdict sentence of an action row, the reason sentence and a row's role and note in the evaluation table. It judges nothing: it words the server's note_parts and codes. Reads MUREO_REPORTS_FORMAT at call time, so it loads after it and before reports_display.js and dashboard_ops_history_detail.js (the ops history section's expanded row, which dashboard_ops_history_section.js binds at load, #851).
    • reports_order.js → window.MUREO_REPORTS_ORDER — the Reports index card order (localStorage, and the two ways the grid is reordered).
    • reports_triage.js → window.MUREO_REPORTS_TRIAGE — the multi-client triage layer (#651): which clients need attention, the ranking (REPORTS_TRIAGE_KINDS — the array index IS the rank), and what to run about each — plus how the layer is SHOWN: one row per kind, the top few of them, and which rows an operator has closed (keyed to a fingerprint of what the row said, so a changed finding comes back). Reads MUREO_REPORTS_LOGIC off the page at call time, so it loads after it.
    • reports_overview.js → window.MUREO_REPORTS_OVERVIEW — the Reports index view's own decisions: which view the section shows (the left menu always means the client list; a period switch or a status refresh keeps the operator where they are), the portfolio figures above the grid, and the "what mureo did today" feed (dated by the summary's server_today — this module never asks the browser what time it is).
    • reports_index.js → window.MUREO_REPORTS_INDEX — the list screen's whole model, composed once per render from the rows and the summaries already fetched (buildReportsIndexModel): the alert layer, the per-client health in grid order, the health split, the band and the portfolio figures. It decides nothing itself — it composes reports_triage.js, reports_overview.js and reports_hero.js, read off the page at call time, so it loads after all three. healthByIndex is the one place triageClientHealth is run per client, and buildReportsIndexModel the one place triageHealthCounts is called: the cards and the roster rows are handed that array, and the band, the chips and the portfolio strip are handed the counts built from it. No dashboard_reports*.js scans the alert layer for a health (#715). It also counts how many of the roster mureo could not fetch at all (unreachable, #714) — a fact about the REQUEST, stated in one line under the band; those clients keep the bucket they had, so nothing below this module grades a client on a failed fetch.

    Each carries an inert module.exports tail so node --test tests/js/*.test.js executes the exact bytes the browser is served, and each is listed in _STATIC_ALLOWLIST with its <script> ahead of dashboard.js (which throws at load naming any that is missing). tests/js/browser_contract.test.js asserts the shipping contract for every module from one table — add a row when you add a module.

    Interaction tests. tests/js/dom_harness.js (a helper, not a suite) parses the real app.html, evaluates the real modules and dashboard.js against it, and answers "would an operator SEE this?" from the real app.css — modelling the one piece of cascade that decides it, the UA's [hidden] { display: none } versus an author display. Use it whenever a change is about what a click DOES: the v0.12.0 health filter shipped green because every test asserted hidden was set on the right cards, and .reports-client-card-item { display: flex } meant nothing moved. Anything hidden by setting .hidden needs a [hidden] rule of its own; that list is asserted in tests/js/reports_filter_interaction.test.js. Put new decision logic in one of these rather than in dashboard.js; a grep-pin cannot catch an inverted condition.

Credential Security

  • Never hardcode API keys, tokens, or secrets in source code
  • Credentials are loaded from ~/.mureo/credentials.json (priority) or environment variables (fallback)
  • Google Ads: GOOGLE_ADS_DEVELOPER_TOKEN, GOOGLE_ADS_CLIENT_ID, GOOGLE_ADS_CLIENT_SECRET, GOOGLE_ADS_REFRESH_TOKEN
  • Meta Ads: META_ADS_ACCESS_TOKEN, META_ADS_APP_ID, META_ADS_APP_SECRET
  • Amazon Ads: AMAZON_ADS_CLIENT_ID plus either AMAZON_ADS_ACCESS_TOKEN or both of AMAZON_ADS_REFRESH_TOKEN / AMAZON_ADS_CLIENT_SECRET; optional AMAZON_ADS_REGION, AMAZON_ADS_ACCOUNT_MODE, AMAZON_ADS_PROFILE_ID, AMAZON_ADS_ACCOUNT_ID, AMAZON_ADS_MANAGER_ACCOUNT_ID
  • The auth_setup.py wizard writes credentials to ~/.mureo/credentials.json
  • No log line may carry a token, secret or credential value — log the field name, the exception type, or a scrubbed detail, never the value. Since #581 the configure server installs a real handler (mureo/logging_setup.py → ~/.mureo/logs/configure.log, MUREO_LOG_LEVEL to raise it), so a leaky logger.* call is now a leak on disk rather than a dead line. HTTP access logs go through safe_http_log_line, which drops query strings (an OAuth callback carries ?code=)

BYOD Mode (Bring Your Own Data)

mureo byod import <file>.xlsx lets a user analyze their real ad-account data locally without OAuth. The bundle importer (mureo/byod/bundle.py) opens the workbook with openpyxl, dispatches recognized adapters by header signature, and writes per-platform CSVs under ~/.mureo/byod/<platform>/. When ~/.mureo/byod/manifest.json registers a platform, mureo/client_factory.py routes that platform's MCP tool calls to a CSV-backed client (mureo/byod/clients.py) instead of the live API. Per-platform: imported platforms = BYOD; un-imported = Live API (or _no_creds error if no credentials). Supported BYOD platforms: google_ads, meta_ads. GA4 and Search Console remain on the Live API OAuth path.

When working on mureo:

  • New ad-platform handlers MUST call mureo/client_factory.get_*_client(...) for the BYOD path; for the remaining platforms (Search Console, GA4), real-mode dispatch can call create_*_client directly to keep test mocks at mureo.mcp._handlers_*.create_*_client working.
  • Google Ads and Meta Ads clients are opened only through mureo.client_factory.open_google_ads_client / open_meta_ads_client (#875): each owns the credential precedence, BYOD routing, workspace scoping, id check (Google: numeric; Meta: canonicalized to act_) and the shared GOOGLE_ADS_THROTTLER / META_ADS_THROTTLER, and raises NoCredentialsError / AccountIdRefused (a ValueError subclass for a scope refusal or a non-numeric Google id) for the caller to map; any other exception from credential loading or client construction is a fault and propagates unchanged. open_meta_ads_client is a coroutine and refreshes a file-sourced token. Tests patch mureo.auth.load_google_ads_credentials, mureo.auth.create_google_ads_client, mureo.client_factory.byod_has and mureo.core.credential_source.current_google_source (Google), and mureo.auth.load_meta_ads_credentials, mureo.auth.create_meta_ads_client, mureo.auth.refresh_meta_token_if_needed, mureo.client_factory.byod_has and mureo.core.credential_source.current_meta_source (Meta).
  • BYOD clients are read-only. Mutation method name prefixes (create_, update_, delete_, pause_, resume_, enable_, disable_, apply_, publish_, submit_, attach_, detach_, approve_, reject_, cancel_, set_, patch_, add_, remove_, send_, upload_, replace_, boost_, end_, duplicate_, export_) return {"status": "skipped_in_byod_readonly"}. The authoritative list is MUTATION_PREFIXES in mureo/core/tool_names.py (26 prefixes; the BYOD clients import it from there, #879).
  • New bundle adapters live in mureo/byod/adapters/<platform>.py and must implement has_tab(workbook) (returns True when the workbook contains the adapter's required sheet/header signature) and normalize_from_workbook(workbook, dst_dir) (writes CSVs to dst_dir, returns an ImportResult). Both adapters (Google Ads and Meta Ads) import the shared helper sanitize_cell from mureo/byod/adapters/_csv_safe.py and apply it to every user-controlled cell against CSV injection (it prefixes formula triggers =, +, -, @, tab, CR with a single quote), then write to the mureo internal schema documented in docs/byod.md.
  • Bundle imports MUST never escape ~/.mureo/byod/ — bundle.py and installer.remove_platform both enforce path-traversal guards.
  • See docs/byod.md for the user-facing walkthrough and the recommended Saved Report configuration for Meta Ads (multilingual: 9 locales).

GAQL Injection Prevention

Google Ads queries use GAQL (Google Ads Query Language). When constructing queries:

  • Never interpolate user input directly into GAQL strings
  • Use parameterized values for customer_id, campaign_id, etc.
  • Validate ID formats before use (numeric strings only)
  • Route all IDs/dates through the dedicated validator mureo/google_ads/_gaql_validator.py. Its ID pattern uses ASCII [0-9]+ (not \d), so full-width / Unicode digits (e.g. 123, ٣٤٥) are rejected rather than silently accepted and interpolated (#441).

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.