Claude Cursor GitHub Copilot Skill

explore

Use this whenever you need to know what is actually in a database, warehouse, or DuckDB file before you trust it: ranked inventory of what exists, column profiles, PII detection, grain and data-quality problems, verified join inference, Mermaid ER diagrams, guarded ad-hoc SQL pro

LLM Mart · 0 points · 1 views 5 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download exmergo-dex-skills_explore-9823c5c.zip · 28 KB
exmergo/dex 25 8 forks Apache-2.0 Updated 1d ago
Part of exmergo/dex — 3 skills

Install

skills CLI npx skills add https://github.com/exmergo/dex/tree/main/skills/explore
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install exmergo-dex@llmmart
Git git clone https://github.com/exmergo/dex.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole exmergo/dex collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Explore

Make sense of a warehouse or a local DuckDB database the way an analytics engineer does: rank what matters, drill selectively, and persist a draft map. This is the flagship, fully read-only skill. It absorbs profiling and relationship inference as capabilities; they are not separate skills.

How to drive it

Run the engine through the wrapper. It prints one sanitized JSON envelope and nothing else; read the envelope and decide the next step.

uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" <subcommand> [flags]

dex runs its engine through uv, which is a prerequisite and is not installed by Claude Code. If the shell reports uv: command not found, stop and tell the user to install it (curl -LsSf https://astral.sh/uv/install.sh | sh, or brew install uv, or pipx install uv), then re-run. Never fall back to raw Python, pip, or a database CLI to do the work another way: the guardrails live in the engine, so any other path is unguarded.

The first command in a fresh environment installs the engine, so it can take tens of seconds where later ones take well under a second. --warm pays that install up front and exits without running anything:

uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" --warm

Offer it once at setup. It is not something to run before an ordinary command.

If the user has no warehouse to point at and wants to see what dex does, demo generates one: a seeded local DuckDB warehouse plus the .dex/config.yml for it, with no credentials and no network, so every subcommand below then runs with no flags. It only ever creates, so it refuses rather than touch a file that already exists. Offer it rather than assuming it: a user who does have a warehouse wants that one read, not a fixture built beside it.

Subcommands, in the usual order:

  1. connect test --path <file.duckdb> confirms a read-only connection and reports capabilities.

  2. explore inventory --rank returns a ranked object summary (counts and sizes, never rows).

  3. explore profile <objects> (space- or comma-separated) returns column profiles, PII flags recorded as (column, category, confidence) and never example values, plus ranked candidate keys, the likely grain, key_evidence, and data-quality warnings (e.g. an id unique on all but 110 rows, which will fan out on joins). candidate_keys is ordered, tightest proven key first, and key_evidence gives one entry per combination considered with its status (reported or suppressed) and the reason. Read it before you trust a composite: a combination unique only because one member is unique on almost every row, or because a money column completes it, is suppressed rather than reported. Where a near-unique column is the real story the warning says so with the ratio, the counts, and how many rows would have to be removed for it to be unique. That last number is the one to act on: it names a source defect to fix rather than a key to work around. A generic *_name flag's confidence is refined by value-shape evidence from the same scan, in both directions: person-shaped values corroborate it, a closed reference vocabulary or long labels de-rate it below the firewall's blocking threshold, and missing evidence changes nothing (the flag itself is never removed). Distinct counts are approximate for scale, but any column that looks unique within approximation noise is escalated to an exact COUNT(DISTINCT) (distinct_count_exact: true), so uniqueness and grain verdicts rest on proof; a ~ prefix marks a number that is still approximate, on a count and on a percentage alike, so a figure quoted without one is exact arithmetic over an exact distinct count on a column with no nulls. A requested object whose cached profile is still fresh (same connector, schema unchanged, within profile_freshness_hours, default 24) is served from the cache (cache_hit_count) instead of re-scanned, so profiling a table map just wrote costs nothing to spend; pass --refresh to force a re-scan when the source changed in a way the free metadata check cannot see.

  4. explore relationships returns inferred and declared joins with confidences, plus notes explaining what the inference examined (so an empty list is meaningful). Add --verify to measure each inferred join with an aggregate overlap probe (orphan fraction, confidence adjusted). A declared join has two sources: a relationships test, and (with --use-project) an entity two semantic models share, which the layer states outright with the key named per model. declared_by on an edge names that entity, semantic_join_count says how many came that way, and the notes call out the ones name-based inference did not find, which is the interesting set: a semantic layer routinely joins columns that share no name at all.

  5. explore map writes or updates the .dex/ cache and returns the map (--verify works here too). Alongside the counts, data.objects gives each top-ranked object its row count, detected grain, best-ranked candidate key, notable columns (each carrying the role that earned it a place: grain, key, join, or a PII flag) and data-quality findings, and data.edges gives the join edges in the same shape explore relationships returns. With --use-project each object also carries semantic_models, the semantic models that sit on that relation, which is what separates a load-bearing table from a merely large one: empty means nothing in the layer reads it. Read that payload instead of chaining profile and relationships to re-derive it; go to those two when you need one object in full, or a value domain, which map never carries. It is budgeted: 25 objects by rank, 12 columns per object, 40 edges, 5 findings per object. Every cap binds in every mode and every elision is counted in notes and in an elided_* field, so an empty notes means nothing was cut. --detail widens the selection to every column and to objects that were inventoried but never profiled, and lifts no cap; it spends nothing, unlike --full. Past 50 objects it profiles only the top 25 by rank and says so in notes (with skipped_count); pass --full to profile everything. On a re-map, objects skipped this run keep their prior profiles (carried_forward_count), each stamped with its own profiled_at so staleness is visible instead of column detail silently vanishing. A selected object whose cached profile is still fresh (same connector, schema unchanged, profiled within profile_freshness_hours, default 24) is reused without a re-scan (cache_hit_count), so re-runs cost nothing to spend; pass --refresh to force a full re-profile when the source changed in a way the free metadata check cannot see (e.g. rows changed but the schema did not). explore relationships and the standalone explore profile reuse fresh profiles the same way.

  6. explore diagram [--full] renders the cached map as a Mermaid ER diagram in data.mermaid. Free and connectionless (it reads the cache, never the warehouse), so it is safe to re-run while shaping the picture. Reproduce the string verbatim in a fenced ```mermaid block so the human can see it, and write it to a .mmd or a markdown file when they want one on disk: the engine deliberately writes no file. Never redraw or "tidy up" the diagram by hand. The glyphs are claims the engine derived from evidence, and a plausible-looking cardinality you supplied is exactly the overclaim this command exists to prevent: declared joins are solid, inferred dotted, and an unverified inference never says "exactly one". A solid line labelled with a semantic entity is a join the semantic layer declares; look the entity up with explore semantic list. Read notes before presenting it, since it states any object or column that was left out; --full widens from the default (profiled, joined objects and their grain, key, join, and PII columns) to everything eligible.

  7. explore query "<SELECT ...>" ["<SELECT ...>" ...] answers ad-hoc questions the fixed commands don't cover: you write the SQL, the engine's query firewall refuses or bounds it. Pass a statement per argument, or --sql-file <path> for a longer list, and ask a whole chain of questions in one call rather than one call each; each statement is judged and answered on its own, so a refusal on one does not cost you the others, and data.results carries one entry per statement. A table you have not profiled, including a model you just built, is profiled for you and the statement then runs, so probing something new is one call rather than three; the envelope says what it profiled, and on a metered connector that profile is priced into the same confirmation as the statements. Results come back row-major and capped; a refusal names the offending column and the fix, so one rewrite is enough. Read ${CLAUDE_SKILL_DIR}/references/probe-playbook.md before writing a probe: it maps common questions to effective probe shapes.

  8. explore cluster <object> [--features a,b,c] [-k N] runs k-means over a bounded sample of the object's numeric columns and returns the segment structure: per-cluster sizes and fractions, centroids (each coordinate is a cluster's mean of that feature, an aggregate), the silhouette score, and, when -k is omitted, the k it picked plus the silhouette sweep it chose from. Requires the .dex/ cache (run map/profile first) so features can be auto-selected from profiled numeric, non-PII, non-key columns; pass --features to choose them yourself (naming a PII column, or a key, opts it in deliberately, and only its mean is ever reported). A key is never a feature: its mean is meaningless, and a fact table is mostly keys plus a handful of measures, so clustering on them just partitions surrogate ranges. Keys are the unique columns, the columns that join out (from the joins map inferred), and the columns named like one; prefer map over a bare profile here, because without inferred joins a foreign key is caught only if its name gives it away. The notes name every excluded column, so check them before trusting a result. Two things the silhouette alone will not tell you, both of which the notes will. A cluster holding under 1% of the sample is an outlier pocket, not a segment, and it pushes the score up precisely because it sits so far out: report that as outlier detection, or re-run with -k to split the bulk. And on connectors that cannot seed a sample the draw changes per run, so two runs can disagree on k; the envelope's sample_repeatable says which case you are in, and comparing runs across different draws is meaningless. Only aggregates cross the boundary: the sample rows are clustered in-process and never enter context. On a metered connector it takes the same cost handshake as the scanning commands below (only the feature columns are scanned, and a dialect-aware sample clause reads a fraction), so surface the estimate and get a budget first. Needs the [cluster] extra (scikit-learn); the wrapper installs it automatically for this subcommand.

  9. explore semantic list|values|query reach the semantic layer: the metrics an author defined, and the semantic models, measures, dimensions and entities they are built out of. Distinct from the warehouse commands above, and from the top-level semantic group, which authors the layer where this queries it.

    list is discovery and returns the layer's objects rather than three lists of names: semantic models (the unit the layer is organized around, each with the transformation model it sits on, its default time dimension, and the physical relation underneath), metrics (which dimensions each can be grouped by, the measures it reads, a ratio's two sides, any filter that makes it a subset, the grains it can be queried at, and time_axis, the physical time column a time grouping resolves to), dimensions (the token to group by, plus the bare definition, owning model, queryable grains and column behind it), entities (one declaration per semantic model, each with its own join key, so the declared join graph is readable), and measures (the aggregation and expression the number is actually made of, which is often a conditional rather than a column). An element defined as an expression carries no column rather than a guessed one. So "which table is behind this metric" is the metric's semantic_models followed to their relations, and explore profile <relation> is the next call; --api exposes no relation at all and declares that in unavailable, so use --local when you need the physical side.

    Three free ways to narrow it, and they compose. --metric <m> keeps those metrics and what they reach. --for-dimension <d> asks the reverse question, returning the metrics groupable by all the named tokens, which is what you want when you know the slice rather than the metric and is also the cheapest way to find the metrics that can go on one chart against one axis. --search <t> takes a word rather than a name and matches it against every element's name and against the project's own label and description. Each names its scope in the payload (scoped_to, for_dimensions, searched_for), so a subset is never mistaken for the layer; an unknown metric or dimension is refused by name, while a search term that matched nothing comes back as a note. The catalog is also capped, with every cut counted in elided and named in notes and --full to lift the caps. elided is always present, so all zeros and no cap notes is the positive statement that this is the whole layer. Prefer narrowing over --full: it decides which part comes back rather than letting a cap decide.

    values <dimension> returns that dimension's value domain, which is what you need before writing a --where filter and the one thing no other dex command can reach on a hosted layer (profile cannot see a semantic dimension). A PII-flagged dimension refuses this command outright rather than being screened, because the whole output is values.

    query takes a positional metric after the explicit mode (with --metric kept for compatibility), a --group-by <entity__dim>, and optional --where, --order-by, --grain and --limit, and returns the metric's values as a capped columnar result. Name flags take a comma-separated list or a repeated flag (--group-by a,b is --group-by a --group-by b); --where is never split, because a filter clause carries its own commas. --grain is checked against the grains the layer reports for the metrics queried, so a refusal names the ones that metric has.

    Two payload fields carry legitimate differences between the backends rather than leaving them to be inferred: dimension_scope says whether a dimension row is one declaration or one groupable path, which is why two backends can report different dimension counts for one layer, and unavailable names fields a backend structurally cannot supply. --local resolves the join graph through MetricFlow where the [semantic] extra is installed, which is what makes its dimension lists the tokens a query can actually use; without it the payload says declarations and a note names the extra.

    Three backends answer these commands, chosen by .dex/config.yml semantic.vendor and semantic.deployment (the older semantic.backend spelling still works), overridable with --local / --api. Those two flags name who executes, not which vendor, and every result reports it as execution (dex or vendor). --local renders the SQL with MetricFlow and executes it through dex's own connector and cost handshake, so cost is surfaced before spend (needs a dbt project parsed at least once, and the [semantic] extra for values and query; list reads the project and needs no extra). --api sends the query to a hosted dbt Cloud deployment (needs a host, an environment id and a DBT_SL_TOKEN, plus [semantic-api], and no local project). The hosted backend is the one place the cost guard cannot apply: dbt Cloud executes server-side, so the result carries an explicit warning that spend is governed there and no --confirm is asked. Either way a PII-shaped grouped or filtered dimension (for example user__email) is refused before the query runs, and on --api the layer's own PII metadata is fetched per metric so a multi-metric query stays authoritative rather than falling back to names.

    The third backend is semantic.vendor: ossie, native Apache Ossie documents read out of the repository with no dbt project and no MetricFlow in the path (needs the [ossie] extra). It is catalog-first: list answers, and values, query and --for-dimension refuse by name, because Ossie specifies interchange metadata and no portable query runtime. Those refusals are the format's shape rather than a missing feature, and each one names the physical route instead: a dimension carries its semantic_model, that model carries its relation, and explore profile then explore query reach the values under the firewall and the cost guard. --api is refused too; Ossie has no hosted deployment.

    Read ${CLAUDE_SKILL_DIR}/references/semantic-playbook.md before running a metric query: a metric's time_axis, filter and measures decide what the number is, and the playbook covers the discovery order, the additivity and time-axis traps this surface is full of, when values answers rather than a query, and what changes when the layer is native Ossie.

Rules of engagement for query: prefer the fixed commands when they answer the question; one probe answers one question; batch related measures into a single query rather than issuing many; aggregates over PII-flagged columns must be measuring (COUNT, APPROX_COUNT_DISTINCT, AVG(LENGTH(...))), never value-carrying (MIN, ANY_VALUE, STRING_AGG). The FROM clause may unnest JSON and array columns in the connector's native idiom, which is the right way to explore schemaless data (for example "which keys appear across every row of this JSON column"): BigQuery t, UNNEST(JSON_KEYS(doc)) AS k, Snowflake t, LATERAL FLATTEN(input => doc) f, Databricks t LATERAL VIEW EXPLODE(json_object_keys(doc)) x AS k, Postgres t, jsonb_object_keys(doc) AS k, Redshift t, UNPIVOT t.doc AS v AT k, DuckDB t, UNNEST(json_keys(doc)) AS u(k), ClickHouse t ARRAY JOIN JSONExtractKeysAndValuesRaw(doc) AS kv (there is no lateral join; ARRAY JOIN is the expansion). The unnested value must come from a column of a table in the query (bare, or through a JSON/array function); unnesting a subquery, another table, a literal, or a generator is refused, and the unnest's outputs inherit the source column's PII flags. A column whose flag was de-rated below the blocking threshold projects normally, with an envelope warning naming it; treat the warning as information for the user, not an error to fix. If the user says a refused column is not personal data, recommend a pii_overrides entry in .dex/config.yml (fully qualified column, optional reason): it unblocks querying immediately, survives re-profiles, and is reviewable in git. Never hand-edit .dex/cache.json to clear a flag. Never fall back to raw Python or a database CLI to run SQL; the firewall path is the only sanctioned one.

Cloud and database targets (BigQuery, Snowflake, Databricks, Postgres, Redshift, ClickHouse)

A remote warehouse or database replaces --path with connector config. Start with connect test --connector <name> (or set connector: plus the matching block in .dex/config.yml: bigquery: with project and a datasets allowlist, snowflake: with the pinned warehouse and a databases allowlist, databricks: with the pinned SQL warehouse and a catalogs allowlist, postgres: with a schemas allowlist, redshift: with the Serverless workgroup and a schemas allowlist). Credentials are discovered, never asked for: if the envelope reports missing or expired credentials, relay the fix it names (for BigQuery gcloud auth application-default login; for Snowflake a connections.toml entry or SNOWFLAKE_* env; for Databricks databricks auth login or DATABRICKS_* env; for Postgres DATABASE_URL, PG* env, or a pg_service.conf entry; for Redshift the AWS credential chain (aws configure, AWS_* env) or REDSHIFT_* env) and never ask the user to paste a key, token, or password.

On a metered connector, scanning commands (profile, map, relationships, query) run a two-step handshake. The first call returns needs_confirmation with an estimate in cost.estimate, a per-table breakdown where relevant, and the unit it is counted in: bytes on BigQuery, warehouse-seconds on Snowflake (credits alongside) and Databricks (DBUs), compute-seconds on Redshift (RPU-hours), database-seconds on Postgres and ClickHouse (no dollars; the guarded quantity is load). Surface the estimate to the user in human units, get an explicit budget from them, and re-issue the same command with --confirm and --budget <magnitude> in that unit. Never invent a budget the user did not agree to, and never retry with a raised budget on an over-ceiling refusal without asking. Metadata is free (connect test, inventory run immediately), and OK envelopes report actual spend under data.spend.

An over-ceiling refusal now carries a calibration line drawn from .dex/spend.jsonl: what this connector's last few settled commands actually billed as a fraction of what they were estimated at, or a sentence saying the project has too little history to say. On a partitioned or clustered warehouse a dry-run estimate is an upper bound, so this is often the difference between a budget that admits the work and one that does not. Relay it verbatim when you surface the refusal, and note the part callers get wrong: the ceiling is checked against the estimate, so a budget set at the observed fraction of the estimate is refused again. It is still the user's decision, never yours.

When a needs_confirmation envelope carries suggested_session_ceiling, the project has never decided whether the day's total spend is bounded, and this is the one time it is asked. Surface it beside the per-command estimate and get the user's answer: --session-ceiling <value> sets a cumulative cap for the project (the suggestion is five times this command's estimate, a starting point, not a recommendation), and --no-session-ceiling records that the project runs unbounded. Either one is written to .dex/config.yml and reported as a diff, and nothing asks again. Add it to the same re-issue that carries --confirm --budget, or the confirmed run will stop once to ask. Never answer it on the user's behalf: it is a durable project setting, not a per-command flag.

On BigQuery a profiling estimate holds a 10 MB floor per table for each escalation query a profile may still issue after its aggregate scan, so on a warehouse of many small tables most of the number can be reserve for work that never happens. Both the handshake and the over-ceiling refusal report that split (reserved_bytes and reserved_queries, and in the prose). Pass it on when you surface the estimate: whether a number is scan or reserve changes whether raising the budget is buying work or headroom.

When an estimate is larger than the work deserves, narrow the scope rather than raise the budget. --scope (repeatable) bounds a command to part of the configured source allowlist, in the connector's own vocabulary: a dataset on BigQuery, a schema or database.schema on Snowflake, a catalog.schema on Databricks, a schema on Postgres or Redshift, a database on ClickHouse (whose identifiers are two-part database.table: there is no catalog level). It is free to resolve, it can only narrow what .dex/config.yml already allows, and a scope that names nothing is refused with the schemas that do exist listed. So explore map --scope <schema> is the first thing to reach for on a warehouse whose full map would be expensive.

Guardrails (enforced in the engine, not here)

  • Read-only against data. The connection is opened read-only and generated SQL is SELECT-only. Never propose a write to source data.
  • Sense-making, not enumeration. Rank and drill selectively; never paste a full schema into context.
  • Profile, don't exfiltrate. Understanding comes from aggregates. PII is flagged, never surfaced, and the query firewall enforces it on your own SQL: values cross the envelope only from profiled columns whose flag is absent or below the blocking threshold, bounded and capped. Only a human's pii_overrides entry clears a flag entirely; never suggest weakening the detection.
  • The two policies in full, in the engine repository: references/pii-policy.md and references/cost-controls.md.
Files (dex)
  • evals
    • evals.json 8.8 KB
      {
        "skill_name": "explore",
        "triggering": {
          "positive": [
            "What's in this warehouse?",
            "What's in my duckdb?",
            "What's in this database?",
            "What data do I have here?",
            "Take a look at data.duckdb and tell me what's there.",
            "Do we have any PII in this database?",
            "Is this data any good?",
            "Which tables actually matter here?",
            "What does the customers table contain?",
            "How do orders and customers join?",
            "Profile the columns in the events table and flag any PII.",
            "How many listings have no matching host in this database?",
            "Run a quick query to check whether order dates have any gaps.",
            "Draw me an ER diagram of this database.",
            "Can you visualize how these tables relate?",
            "Build a staging model for the orders table.",
            "Build a mart that scores each customer for churn risk. Source data: int_sales__orders_enriched. Explore to understand available columns.",
            "Fix the rpt_customer_metrics.sql model. It produces inf and NaN values in calculated columns and does not exclude NULL customer_id. The model reads from int_sales__orders_enriched.",
            "Create models/marts/customer_cohorts.sql showing monthly retention by acquisition cohort. Use the orders and customers source tables.",
            "The finance team says fct_daily_revenue is double counting refunds since the SAP migration. Find the cause and fix it.",
            "What metrics does this repo's semantic layer define, and what tables are behind them?"
          ],
          "negative": [
            "Set up a dbt project in this repo.",
            "Refactor this dbt model and add tests.",
            "Define a revenue metric on top of fct_orders.",
            "What changed in the warehouse and did my dbt project drift?",
            "Build models/marts/basket_composition.sql. Source: ORDERS (order_id VARCHAR, customer_id VARCHAR, ordered_at TIMESTAMP, grand_total DECIMAL, status VARCHAR) and ORDER_LINES (line_id VARCHAR, order_id VARCHAR, sku VARCHAR, qty INTEGER, unit_price DECIMAL). Group baskets by size band and report average margin per band.",
            "Rename the column cust_id to customer_id across models/staging/sap/, about 30 files, and keep the yml docs in sync.",
            "The CI job fails on dbt parse after I merged main. Jinja compilation error in macros/cents_to_dollars.sql, undefined variable. Fix the macro.",
            "Bump dbt-core to 1.10 in requirements.txt and fix the deprecation warnings in dbt_project.yml.",
            "Add a revenue metric to my Ossie semantic model."
          ]
        },
        "evals": [
          {
            "id": 0,
            "prompt": "I just connected a DuckDB file with a few dozen tables. Help me understand what's in it and which tables are worth my attention.",
            "expected_output": "A ranked landscape from inventory and a draft .dex/ map, reported as counts plus the map's own budgeted findings, without dumping the full schema into context.",
            "files": [],
            "assertions": [
              "Drives the engine via the wrapper (explore inventory --rank and/or explore map) and reads the JSON envelope; does not invent results",
              "Reports a ranked shortlist of objects, not all of them; no per-column listing appears for the full warehouse (sense-making, not enumeration)",
              "When map is run, it relays what the envelope returned: the counts (object_count, profiled_count, relationship_count, pii_column_count) and the budgeted findings the payload carries, without pasting the cache contents or re-listing every column",
              "No raw rows and no credentials appear anywhere in the output (clean envelope)"
            ]
          },
          {
            "id": 1,
            "prompt": "Profile the customers table and tell me about data quality and any sensitive columns.",
            "expected_output": "Column profiles built from aggregates plus PII flags; min/max suppressed on string and PII columns; no example values surfaced.",
            "files": [],
            "assertions": [
              "Profiles are aggregate-derived (null_fraction, distinct_count, is_unique), never raw rows",
              "PII is reported as (column, category, confidence) with no example value; the email column is flagged",
              "min_value and max_value are absent (null) for string and PII columns, and present only for safe numeric or temporal columns",
              "Stated as read-only against the data"
            ]
          },
          {
            "id": 2,
            "prompt": "How do the tables in this warehouse relate? Show me the joins.",
            "expected_output": "Inferred joins (and declared ones if a dbt project is present), each labeled by kind with a confidence, derived from metadata rather than by scanning data.",
            "files": [],
            "assertions": [
              "Relationships are returned with from/to datasets and columns and a kind of inferred or declared",
              "Inferred joins carry a confidence and are clearly distinguished from declared (dbt) joins",
              "No raw rows are read to establish the joins; the result is metadata-derived (or verified with --verify aggregate probes)",
              "The output is a relationship summary, not a full schema dump"
            ]
          },
          {
            "id": 3,
            "prompt": "How many orders point at a customer that doesn't exist, and what are the most common order statuses?",
            "expected_output": "Two answers derived through guarded probes: an orphan count from an overlap-shaped query and a top-K status distribution, both via `explore query` after mapping, never via raw Python or a database CLI.",
            "files": [],
            "assertions": [
              "Runs `explore map` (or confirms the .dex cache exists) before probing, because the query firewall requires the cache",
              "Answers through `explore query` via the wrapper; does not open the database with ad-hoc Python, duckdb CLI, or any path around the engine",
              "Probes are aggregate-shaped (counts, GROUP BY on a non-PII column); no SELECT * and no value-carrying aggregate over a PII-flagged column",
              "If a query is refused by the firewall, the agent rewrites it per the refusal message instead of retrying the same shape or routing around the engine",
              "Reports the counts and distribution from the envelope's columnar result; respects and mentions truncation notes if present"
            ]
          },
          {
            "id": 4,
            "prompt": "List the region names in this warehouse. The region table is a 5-row lookup dimension.",
            "expected_output": "After profiling, the region name column's generic flag is de-rated below the firewall threshold by value-shape evidence, so the projection runs; the envelope's PII warning is passed on to the user as a caveat, not treated as an error.",
            "files": [],
            "assertions": [
              "Profiles the table (or confirms the cache) before querying, so the flag carries shape-refined confidence",
              "Projects the column through `explore query` and reports the values with the envelope's PII warning relayed as a caveat",
              "Does not treat the warning as a failure, does not weaken or remove any flag, and does not route around the engine"
            ]
          },
          {
            "id": 5,
            "prompt": "Your query on products.name got refused as PII, but that column is just product titles, not personal data. Fix it properly.",
            "expected_output": "A pii_overrides entry in .dex/config.yml naming the fully qualified column (with a reason), presented for the user's review; never a cache edit or a detector change.",
            "files": [],
            "assertions": [
              "Recommends or writes a pii_overrides entry in .dex/config.yml with the fully qualified column path and a reason",
              "Does not edit .dex/cache.json, does not propose changing the detector or the firewall threshold",
              "Explains that the override survives re-profiles and takes effect at query time immediately"
            ]
          },
          {
            "id": 6,
            "prompt": "Give me a diagram of how the tables in shop.duckdb relate to each other.",
            "expected_output": "A Mermaid ER diagram produced by `explore diagram` after mapping, reproduced verbatim in a fenced mermaid block, with the engine's own line styles and cardinalities left intact and any elision from the notes mentioned.",
            "files": [],
            "assertions": [
              "Runs `explore map` (or confirms the .dex cache exists) before `explore diagram`, because the diagram renders the cache",
              "Produces the diagram through `explore diagram` via the wrapper; does not hand-author Mermaid from the relationships envelope or from its own reading of the schema",
              "Reproduces `data.mermaid` verbatim inside a fenced mermaid block; does not redraw, re-letter, or 'tidy' the glyphs, and does not upgrade an inferred or unverified edge to a solid or exactly-one cardinality",
              "Reports what the notes say was left out (elided objects or columns) rather than presenting a capped diagram as complete",
              "Writes a .mmd or markdown file only if the user asked for a file, and does not claim dex wrote one"
            ]
          }
        ]
      }
      
  • references
    • probe-playbook.md 7.9 KB
      # Probe playbook: effective shapes for `explore query`
      
      A probe is one agent-authored SELECT run through the engine's query firewall. The
      firewall guarantees safety; this playbook is about effectiveness: asking the
      question in a shape that returns a small, decisive answer instead of a wall of
      rows. Map first (`explore map`) when you are getting your bearings: the profile
      usually already holds the answer (null fractions, distinct counts, min/max,
      ranked candidate keys and the reason behind each), and probes exist for the
      questions it does not. But you do not
      have to map before you can probe. A table the engine has not profiled, including
      a model you built moments ago, is profiled as part of answering, so a probe
      against something new costs one call.
      
      Three habits pay for everything else:
      
      - **One probe, one question.** Decide what you are testing before writing SQL,
        and name the output columns after the answer (`orphans`, `dupes`, `coverage`).
      - **Batch related measures into one SELECT.** Eight aggregates over the same FROM
        clause are one scan; eight separate probes are eight, and on a metered
        warehouse you pay for each. Combine counts that share a FROM clause.
      - **Send unrelated probes in one call.** Questions that do not share a FROM
        clause cannot share a SELECT, but they can share a call: pass a statement per
        argument, or `--sql-file <path>` for a longer list. That saves the call, not
        the scan, which is why it is the second choice and not the first. Each
        statement is firewalled and answered on its own, `data.results` holds one entry
        per statement, and a refusal on one leaves the rest intact. The result-size cap
        is the call's rather than each statement's, so keep a batch aggregated: ten
        statements share the budget one statement would have had to itself.
      
      Firewall rules that shape your SQL: values may be projected only from profiled,
      PII-cleared columns; over a flagged column use a measuring aggregate (COUNT,
      COUNT(DISTINCT ...), COUNTIF/COUNT_IF, APPROX_COUNT_DISTINCT, AVG, SUM, STDDEV),
      never a value-carrying one (MIN, MAX, ANY_VALUE, STRING_AGG, ARRAY_AGG). Filters and
      join conditions may reference anything. Results are capped (rows, cell width,
      bytes), and every cut is announced in `notes`, so aggregate first rather than
      paging.
      
      ## The recipes
      
      **1. Join-key overlap.** Does `child.fk` really point at `parent.key`? (Or run
      `explore relationships --verify`, which is this probe productized.)
      
      ```sql
      SELECT COUNT(c.fk)                                   AS nonnull_fk,
             COUNT(DISTINCT c.fk)                          AS distinct_fk,
             COUNT(*) FILTER (WHERE c.fk IS NOT NULL AND NOT EXISTS (
               SELECT 1 FROM parent p WHERE p.key = c.fk)) AS orphans
      FROM child c
      ```
      
      Zero orphans confirms the join; a high orphan fraction says the name-based guess
      was wrong or the parent is incomplete. `--verify` applies the second habit above
      to this recipe for you: the joins that share a child are measured in one
      statement, so it costs what the relations cost rather than what the join count
      costs.
      
      **2. Duplicate distribution.** How badly a key is broken is already in the
      profile: it reports the distinct count, the row count, and how many rows would
      have to be removed for the column to be unique, exactly, whenever the distinct
      count was escalated and the column has no nulls. Probe when you need the *shape*
      of the duplication rather than its size.
      
      ```sql
      SELECT COUNT(*)                          AS rows,
             COUNT(DISTINCT id)                AS distinct_ids,
             COUNT(*) - COUNT(DISTINCT id)     AS surplus_rows,
             MAX(cnt)                          AS worst_repeat
      FROM (SELECT id, COUNT(*) AS cnt FROM t GROUP BY id)
      ```
      
      `worst_repeat` is the column the profile cannot give you, and it is the one that
      separates a double-loaded batch (every repeat is 2) from a single id that
      swallowed the table.
      
      **3. Top-K categorical distribution.** What values dominate a (non-flagged)
      column, and how concentrated is it?
      
      ```sql
      SELECT status, COUNT(*) AS n
      FROM orders GROUP BY 1 ORDER BY 2 DESC LIMIT 10
      ```
      
      For a PII-flagged column, take the measuring route instead: `COUNT(DISTINCT x)`
      tells you the cardinality story without surfacing a value.
      
      **4. Null / blank breakdown.** Nulls are profiled already; blanks and sentinels
      are not.
      
      ```sql
      SELECT COUNT(*)                                        AS rows,
             COUNT(*) FILTER (WHERE TRIM(col) = '')          AS blank,
             COUNT(*) FILTER (WHERE col IN ('N/A', 'none'))  AS sentinel
      FROM t
      ```
      
      **5. Date coverage.** Is the table continuous, and where does it end?
      
      ```sql
      SELECT MIN(created_at)                    AS first_day,
             MAX(created_at)                    AS last_day,
             COUNT(DISTINCT CAST(created_at AS DATE)) AS days_present,
             DATEDIFF('day', MIN(created_at), MAX(created_at)) + 1 AS days_span
      FROM t
      ```
      
      `days_present` well below `days_span` means gaps; probe the suspect range with a
      bucketed count (recipe 7).
      
      **6. Orphan / coverage rate between layers.** What fraction of entity A ever
      appears in fact B?
      
      ```sql
      SELECT COUNT(*)                                          AS customers,
             COUNT(*) FILTER (WHERE EXISTS (
               SELECT 1 FROM orders o WHERE o.customer_id = c.id)) AS with_orders
      FROM customers c
      ```
      
      **7. Distribution sketch via buckets.** The shape of a numeric column without
      pulling rows.
      
      ```sql
      SELECT WIDTH_BUCKET(amount, 0, 1000, 10) AS bucket,
             COUNT(*)                          AS n,
             AVG(amount)                       AS bucket_avg
      FROM payments GROUP BY 1 ORDER BY 1
      ```
      
      **8. Sensitive-column shape check.** Everything useful about a flagged column
      that can cross the envelope:
      
      ```sql
      SELECT COUNT(email)                    AS present,
             COUNT(DISTINCT email)           AS distinct_vals,
             AVG(LENGTH(email))              AS avg_len,
             COUNT(*) FILTER (WHERE email NOT LIKE '%@%') AS shape_violations
      FROM users
      ```
      
      Recipes 4, 6, and 8 above use `FILTER (WHERE ...)`, which is not available on
      BigQuery BigQuery's engine will reject that clause outright, and the firewall
      will not catch it first (it parses fine). On BigQuery, spell the same batched
      filtered count as `COUNTIF(cond)` instead, e.g. recipe 4's blank/sentinel
      breakdown becomes:
      
      ```sql
      SELECT COUNT(*)                            AS rows,
             COUNTIF(TRIM(col) = '')             AS blank,
             COUNTIF(col IN ('N/A', 'none'))     AS sentinel
      FROM t
      ```
      
      `COUNTIF(cond)` is equivalent to `COUNT(*) FILTER (WHERE cond)` and passes the
      firewall the same way: the condition is a filter, not a projected value.
      
      ## When a probe is refused
      
      The refusal names the column, its PII category, and the fix. Rewrite once: swap
      the value-carrying expression for a measuring one, or drop the column from the
      projection. Do not retry the same shape and do not route around the engine with
      Python or a database CLI. A refusal naming a table the connection does not have
      is a real answer: check the name, or build the model into the target you are
      querying. It will not be fixed by profiling.
      
      Two newer paths the refusal may name:
      
      - A refusal on a column the user says is not personal data (a region label, a
        product line) is resolved with a `pii_overrides` entry in `.dex/config.yml`
        naming the fully qualified column, with an optional reason. It takes effect on
        the next query without re-profiling. Recommend the entry; never edit the
        cache.
      - If the refusal suggests re-profiling, the cache predates value-shape
        profiling: one `explore profile <table>` recomputes the flag's confidence
        with shape evidence and may clear the block on its own.
      
      ## When a probe runs with a PII warning
      
      A projection of a column whose flag sits below the blocking threshold runs and
      adds a warning to the envelope naming the column, category, and confidence.
      That is the designed behavior for de-rated reference columns (a region or
      nation dimension), not an error: pass the caveat on to the user alongside the
      result, and if they confirm the column is personal data after all, drop it from
      later probes.
      
    • semantic-playbook.md 10.3 KB
      # Semantic playbook: reading a metric before you trust its number
      
      `explore semantic` reaches a semantic layer: metrics, and the semantic models,
      measures, dimensions and entities they are built out of. A metric query is not a
      probe. There is no SQL to inspect, the layer decides what the number means, and
      the same metric grouped two ways can return two numbers that are both correct and
      not comparable. So the work is almost all in `list` and `values`, and `query` is
      the short last step.
      
      Three habits, in order of how much they save:
      
      - **Scope the catalog rather than reading the layer.** A whole layer's catalog is
        one payload and most of it is about something else. `list --metric <m>` if you
        know the metric, `list --for-dimension <d>` if you know the slice, or
        `list --search <word>` if you know neither. All three are free, none costs a round
        trip beyond the first, and each names its scope in the payload.
      - **Read the metric's caveats before its dimensions.** `time_axis`, `filter` and
        `input_measures` change what the number is. The dimension list only changes how
        it is cut.
      - **Get the value domain before writing a filter.** `values <dimension>` is the
        only way to know what a `--where` may filter to, and on a hosted layer it is the
        only dex command that can reach it at all.
      
      ## Discovery order
      
      1. **`list`, scoped.** Start narrow. `--search` takes a word and matches it
         against every element's name and against the project's own label and
         description, so "revenue" finds the metrics an author wrote about revenue.
         `--for-dimension pricing_tier` answers "what can I slice by this", and is also
         the cheapest way to find the metrics that can go on one chart against one axis,
         because it returns the metrics groupable by **all** the tokens named.
      2. **Read the caveats on the metric you picked** (next section). Stop here if they
         say the number is not the one you want; going back is cheaper than a wrong
         answer presented confidently.
      3. **`values <dimension>`** for every dimension you plan to filter on.
      4. **`query`**, once, with the group-by and filter you have now justified.
      
      Before concluding anything about the layer, read four payload fields:
      
      - `dimension_scope`. `queryable_paths` means every row is a token you can paste
        into `--group-by`. `declarations` means the list is the single-hop declared view
        and a query can group by more than it names, which is what `--local` reports
        without the `[semantic]` extra. Two backends reporting different dimension
        counts for one layer is this field, not a bug. A native Ossie layer always
        reports `declarations`, because the format states no join graph to resolve
        through.
      - `unavailable`. Fields the answering backend structurally cannot supply. An
        absent `label` here means "this path cannot carry one", not "the project
        declared none", and the difference decides whether looking elsewhere is worth
        it. `--api` has no `relation` on a semantic model at all, so use `--local` when
        you need the physical side.
      - `scoped_to`, `for_dimensions`, `searched_for`. Which narrowing produced this
        catalog. Present means you are holding a subset.
      - `elided`. What the payload cap cut, per element kind. All zeros and no cap notes
        means this is the whole layer. Non-zero means narrow the question rather than
        concluding the layer does not have something.
      
      ## Reading a metric
      
      **`time_axis` first.** A layer's time token (`metric_time`) is one name over many
      physical columns: it resolves to each measure's own aggregation time dimension.
      One entry is the ordinary case. **More than one entry means the metric's measures
      aggregate over different timestamps**, so grouping by `metric_time` buckets part
      of the number by one column and the rest by another, invisibly, in a result that
      looks like any other. Worse, one of those columns is often null on rows the other
      one has, and those rows are then dropped. If you see two entries, either group by
      a named time dimension instead, or say in your answer which parts of the number
      are bucketed how.
      
      **`filter` next.** A metric with a filter measures a subset. That is invisible in
      the result and it is usually the explanation for a number lower than expected.
      
      **`input_measures`, followed through to the measures.** A measure's `agg` and
      `expr` are what the number actually is, and a measure is often a conditional
      expression rather than a column: `sum(case when ... then 1 else 0 end)` counts
      something narrower than its name suggests. This is also where additivity comes
      from. A `sum` over a bare column is additive and can be totalled across any
      grouping; an `average`, a `median`, a `count_distinct` and any ratio are not, so
      the sum of the grouped rows is not the ungrouped total and you must not present it
      as one.
      
      **A ratio's two sides.** `composition.numerator` and `composition.denominator`
      name other metrics (or measures). Read both. A ratio is never additive. Two ratios
      that share a denominator can be compared and two that do not usually cannot. And
      if the two sides live in different semantic models, a group-by valid on one may
      not be valid on the other.
      
      **`queryable_granularities`.** The grains the layer will accept for this metric,
      which is per metric rather than a fixed ladder. `--grain` is validated against it,
      so a refusal here names what the metric does have. An **empty list on a dimension
      is an answer**: a categorical dimension has no grain, so do not ask for one.
      
      ## `values` versus a query
      
      `values <dimension>` returns one dimension's value domain. Reach for it whenever
      you are about to write a `--where`, and before telling a user what the categories
      are.
      
      Read `scoped_to` on the result, because it changes what the values mean. Empty
      means these are the domain of the column behind the dimension. A metric name means
      dex had to reach the dimension through that metric, because a dimension behind a
      join has no other rendering: neither layer will run a distinct-values query with
      no measure to join from. Those are then the values **present for that metric**,
      which can be narrower than the column's own domain, and the note names the other
      metrics that reach it. Pass `--metric` to choose one yourself.
      
      It never claims an exact cardinality, which would cost a second scan. A large
      domain comes back capped and `truncated`, and says so.
      
      Use a `query` instead when you want the distribution rather than the domain: group
      the metric by the dimension and read the sizes. That costs a query where `values`
      often does not.
      
      ## When a command is refused
      
      - **A PII-flagged dimension on `values` refuses the command outright**, rather than
        being screened out of a larger answer, because the whole output is values. The
        refusal names the two durable ways to clear a dimension reviewed as not personal
        data: a `pii_overrides` entry in `.dex/config.yml`, or `meta: {pii: false}` on
        the dimension in the project that declares it. Do not work around it by querying
        the same column another way.
      - **A PII-shaped `--group-by` or `--where` token refuses the query** before it
        runs. `user__email` is the standing example: the token's own shape is enough.
      - **A filtered query on a backend that cannot read its own filter dialect is
        refused** rather than run with the filter half unscreened. Move the condition
        into `--group-by`, or use a backend that reads its filters.
      - **An unknown metric or dimension name is refused by name.** A search term that
        matched nothing is not: it comes back as a note, because a substring matching
        nothing is an honest answer about the layer's words.
      - **A backend that declares a field unavailable refuses the command that needs
        it**, rather than answering from the empty value. `--for-dimension` on a layer
        whose backend lists `dimensions` under `unavailable.metrics` is the standing
        example. Read `unavailable` before concluding anything from an empty list.
      
      ## When the layer is native Apache Ossie
      
      `vendor: ossie` means the layer is native Ossie documents in the repository rather
      than a dbt project. `list` works and reads the same shape as anywhere else, and
      the payload says so on `vendor`. Three things differ, and each is a property of the
      format rather than a missing feature:
      
      - **`query` and `values` refuse.** Ossie specifies interchange metadata and not a
        portable query runtime, so there is no filter grammar and no join planning to
        render a governed statement from. Take the physical route instead: a dimension
        names its `semantic_model`, that model names its `relation`, and `explore
        profile` then `explore query` reach the values under the firewall and the cost
        guard. Do not present the refusal as dex being unable to reach the layer.
      - **`--for-dimension` refuses**, because Ossie states no metric-to-dimension
        relationship at all. `metrics[].dimensions` is empty and `unavailable.metrics`
        says why. An empty groupable list here is not "this metric can be grouped by
        nothing".
      - **A metric with no `semantic_models` is a metric whose lineage did not resolve**,
        not one that reads nothing. Ossie carries no metric-to-dataset reference, so dex
        reports only what a qualified reference in the expression proved, and reports
        nothing when none did.
      
      An element with no `column` is also normal here and the notes say which of four
      reasons applies: a computed expression, a quoted identifier, a query-backed
      dataset source, or a field written only in a non-SQL dialect. In every one of them
      dex declined to guess a column rather than failed to find one, so do not go looking
      for the column yourself and do not treat the absence as a data problem.
      
      ## Cost, and which backend answered
      
      Every result names `execution`. `dex` means dex rendered the statement and ran it
      through its own connector, so the cost handshake applied and spend was surfaced
      before it happened. `vendor` means the semantic layer ran it: dex never held a
      statement it could price or cap, so the result carries an explicit warning that
      spend is governed there. Do not present a hosted result as cost-guarded, and do
      not treat the absence of an estimate as "it was free".
      
      `list` and the catalog side cost no warehouse query on any backend: one GraphQL
      round trip hosted, one compiled-artifact read locally, one repository read for a
      native layer. `values` and `query` do spend on the two dbt backends. On a native
      Ossie layer neither runs at all, so the only spend on that path is the
      `explore profile` and `explore query` you reach for instead, and those carry
      their own estimate and confirmation.
      
  • scripts
    • run.py 16.1 KB
      # /// script
      # requires-python = ">=3.11"
      # dependencies = []
      # ///
      """Thin PEP 723 wrapper that drives dex-core via the command contract.
      
      The skill never re-implements logic. It forwards its arguments to the pinned
      `dex-core` engine and lets the engine print the sanitized JSON envelope. Run it
      with `uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" <dex
      subcommand> ...`.
      
      `uv` is a hard prerequisite: it is what installs and runs the engine. When it is
      absent this wrapper refuses with an error envelope naming the install command,
      rather than letting the exec fail with a traceback. Invoked through `uv run` the
      shell fails first (`uv: command not found`), which is why each SKILL.md also tells
      the agent what that message means.
      
      Two execution modes, chosen automatically:
        - Monorepo checkout (this repo): `packages/dex-core` is found above the skill,
          so the engine runs from an editable local install. This is what makes the
          wrapper work before the package is published.
        - Installed plugin: no local package is present, so the pinned PyPI release is
          installed hermetically by uv.
      
      That environment is uv's own, and `--no-project` is what keeps it that way. Without
      it uv discovers whatever Python project the caller happens to be standing in, builds
      it, writes a `.venv/` and a `uv.lock` into their repo, and puts their dependencies on
      the engine's import path: dex would be leaving unreviewed artifacts in a repo it was
      asked only to read, and running against a closure it did not pin.
      
      `--warm` materializes that environment and exits without running a command, so the
      install can be paid once out of band instead of on the first caller's clock (see
      _warm).
      
      The engine version is pinned; the *extras* are chosen at runtime, so one published
      release serves every warehouse and the default install stays light. The connector
      extra comes from the active connector; two commands need more than a warehouse
      client and say so by being run (see _feature_extras). This wrapper is
      stdlib-only and runs before the engine is installed, so it resolves the connector
      itself (it cannot import the engine) with the same precedence the engine uses:
      an explicit --connector flag, then the top-level `connector:` in the
      `.dex/config.yml` found by walking up from the run directory to the git root (the
      way git and dbt find their project), then DuckDB. The walk-up must mirror the
      engine's: if it did not, a run from a subdirectory would install the DuckDB extra
      while the engine resolves the project's real connector and then fails for want of
      that connector's deps. The guess only picks which extra to install; the full argv
      is still forwarded, so the engine stays authoritative for the actual connection
      and a wrong guess surfaces as a clean error envelope.
      
      `DEX_CORE_VERSION` is the single line bumped at release time, by
      scripts/prepare_release.sh before the tag; nothing else here changes per release.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import shutil
      import subprocess
      import sys
      import time
      from pathlib import Path
      
      # Rewritten by scripts/prepare_release.sh to the tagged version. The connector
      # extra is deliberately NOT part of this pin: it is chosen at runtime (see
      # _resolve_connector), so a release artifact is connector-neutral.
      DEX_CORE_VERSION = "1.12.3"
      
      # Connector id -> packaging extra. The engine's connector ids and the pyproject
      # extras share names, so this is the identity set today. An unknown or unset
      # connector falls back to the light DuckDB on-ramp and lets the installed engine
      # emit the canonical error rather than the wrapper guessing wrong.
      _KNOWN_CONNECTORS = (
          "duckdb",
          "snowflake",
          "bigquery",
          "databricks",
          "postgres",
          "redshift",
          "clickhouse",
      )
      _DEFAULT_CONNECTOR = "duckdb"
      
      # The one flag the wrapper answers itself. It is stripped before anything else
      # reads the argv, so it never reaches the engine and never lands in the positionals
      # that pick the extras.
      _WARM_FLAG = "--warm"
      
      
      def _connector_from_config(config_path: Path) -> str | None:
          """Read the top-level scalar `connector:` from .dex/config.yml, stdlib only.
      
          This bootstrap script has no YAML dependency and only needs enough to pick the
          right extra to install, so it scans for a single unindented `connector:` key.
          The engine remains the source of truth for the full config; anything richer or
          malformed here just falls through to the DuckDB default.
          """
      
          try:
              text = config_path.read_text(encoding="utf-8")
          except OSError:
              return None
          for line in text.splitlines():
              if line.startswith("connector:"):  # top-level only; indented keys ignored
                  value = line.split(":", 1)[1].split("#", 1)[0].strip().strip("'\"")
                  return value or None
          return None
      
      
      def _find_config(start: Path) -> Path | None:
          """Nearest ancestor `.dex/config.yml` at or above `start`, mirroring the
          engine's resolution: walk up to the enclosing git repo (the ceiling), and
          without one do not walk above `start`. Anchors on the file so a subdirectory
          holding only a `.dex/` cache never shadows the real config higher up."""
      
          start = start.resolve()
          ceiling = start
          for directory in (start, *start.parents):
              if (directory / ".git").exists():
                  ceiling = directory
                  break
          for directory in (start, *start.parents):
              candidate = directory / ".dex" / "config.yml"
              if candidate.is_file():
                  return candidate
              if directory == ceiling:
                  break
          return None
      
      
      def _resolve_connector(argv: list[str], cwd: Path) -> str:
          """Pick the connector whose extra we install, mirroring the engine's order:
          explicit --connector, then the walked-up .dex/config.yml, then DuckDB."""
      
          # allow_abbrev=False and parse_known_args so we only peek at these two flags
          # and never consume or reorder the argv that is forwarded to the engine.
          parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
          parser.add_argument("--connector")
          parser.add_argument("--repo-root", default=".")
          known, _ = parser.parse_known_args(argv)
      
          connector = known.connector
          if connector is None:
              config_path = _find_config(cwd / known.repo_root)
              if config_path is not None:
                  connector = _connector_from_config(config_path)
          return connector if connector in _KNOWN_CONNECTORS else _DEFAULT_CONNECTOR
      
      
      # Every value-taking flag this peek has to consume, so a flag's *value* is never
      # mistaken for the group, subcommand, or mode being run. The global connection
      # flags, plus the ones `explore semantic` takes, because a metric read as a mode
      # picks the wrong extras. `tests/test_skill_wrapper.py` holds this list to the
      # real parser, since a flag added there and forgotten here fails silently.
      _VALUE_FLAGS = (
          "--connector",
          "--path",
          "--scope",
          "--project",
          "--dataset",
          "--repo-root",
          "--budget",
          "--session-ceiling",
          "--metric",
          "--for-dimension",
          "--search",
          "--group-by",
          "--where",
          "--order-by",
          "--grain",
          "--limit",
      )
      
      # The `explore semantic` modes that can render a statement here. Bare
      # `explore semantic` lists, so an absent mode is `list`, which renders none on
      # either backend and therefore never needs the renderer.
      _SEMANTIC_RENDERING_MODES = ("values", "query")
      
      
      def _positionals(argv: list[str]) -> list[str]:
          """The bare tokens of an invocation: group, subcommand, and whatever follows.
      
          Flag-position agnostic, which is the point: the value flags above are consumed
          so the group and subcommand are the first two bare tokens wherever the
          connection flags sit."""
      
          parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
          for flag in _VALUE_FLAGS:
              parser.add_argument(flag)
          _, remaining = parser.parse_known_args(argv)
          return [tok for tok in remaining if not tok.startswith("-")]
      
      
      def _feature_extras(argv: list[str]) -> list[str]:
          """The extras this invocation needs on top of the connector's.
      
          Two commands need more than a warehouse client, and both are resolved from the
          command rather than installed always, so a repo that never clusters and never
          queries a semantic layer resolves neither scikit-learn nor MetricFlow.
      
          `explore semantic` needs the hosted client on any of its subcommands, which is
          an httpx and nothing heavier. It needs MetricFlow only where a statement might
          be rendered *here*: `list` renders none on either backend, and `--api` sends
          the request to dbt Cloud, which renders and executes it there.
      
          Where the flag is absent the backend is ambient, chosen by `semantic.deployment`
          in a nested config block this bootstrap deliberately does not parse, so a mode
          that could execute either way takes both. That errs toward a heavier install
          and never toward a command that refuses for want of a dependency, which is the
          failure this exists to prevent.
          """
      
          positionals = _positionals(argv)
          if positionals[:2] == ["explore", "cluster"]:
              return ["cluster"]
          if positionals[:2] != ["explore", "semantic"]:
              return []
          mode = positionals[2] if len(positionals) > 2 else "list"
          if mode in _SEMANTIC_RENDERING_MODES and "--api" not in argv:
              return ["semantic-api", "semantic"]
          return ["semantic-api"]
      
      
      def _engine_spec(extras: str, skill_dir: Path | None = None) -> list[str]:
          skill_dir = skill_dir or Path(
              os.environ.get("CLAUDE_SKILL_DIR", Path(__file__).resolve().parent.parent)
          )
          local_pkg = (skill_dir / ".." / ".." / "packages" / "dex-core").resolve()
          if local_pkg.is_dir():
              # Resolve the local package WITH the resolved extras (a plain path drops
              # extras). Non-editable is fine: the engine is imported fresh each run.
              return ["--with", f"exmergo-dex-core[{extras}] @ {local_pkg.as_uri()}"]
          return ["--with", f"exmergo-dex-core[{extras}]=={DEX_CORE_VERSION}"]
      
      
      def _extras(argv: list[str]) -> list[str]:
          """The full extras list for an invocation: the connector's, plus whatever the
          command itself needs.
      
          Warm-up and a real run both resolve here, on the same argv, which is what stops
          a warmed environment from disagreeing with the one the next command asks for."""
      
          return [_resolve_connector(argv, Path.cwd()), *_feature_extras(argv)]
      
      
      def _uv_run(engine_spec: list[str], tail: list[str]) -> list[str]:
          """The single uv invocation this wrapper makes.
      
          `--no-project` is a correctness flag before it is a fast one: see the module
          docstring for what uv does to the caller's repo without it. The latency follows
          from the same thing, because installing the caller's project is far more work
          than resolving the engine."""
      
          return ["uv", "run", "--no-project", *engine_spec, *tail]
      
      
      def _envelope(
          status: str,
          *,
          data: dict[str, object] | None = None,
          errors: list[str] | None = None,
          reason: str | None = None,
      ) -> str:
          """One JSON line in the engine's envelope shape, built by hand.
      
          The wrapper prints an envelope for the few things that happen before the engine
          exists to print its own: the missing-uv refusal and the warm-up. It cannot
          import `exmergo_dex_core.envelope` for precisely that reason, so the keys are
          mirrored here and have to stay in step with Envelope and Cost. `cost.paradigm`
          stays null throughout, because nothing here opens a connection and `free_local`
          is a positive claim that the connector in play bills nothing rather than a
          stand-in for "no connector was involved". `reason` is populated only on an
          error, the same rule the engine's Reason enum follows.
          """
      
          return json.dumps(
              {
                  "status": status,
                  "data": data or {},
                  "cost": {"paradigm": None, "estimate": None, "ceiling": None},
                  "warnings": [],
                  "diffs": [],
                  "errors": errors or [],
                  "reason": reason,
              }
          )
      
      
      def _warm(argv: list[str]) -> int:
          """Materialize the engine environment and exit, without running a command.
      
          Nearly all of a first command's latency is uv resolving and installing the
          engine's closure. This pays it out of band, at container build, plugin install,
          or a CI setup step, so no interactive caller waits for it.
      
          The extras come from `_extras` on the argv that remains after `--warm` is
          stripped, so warm-up can never install something a later command contradicts:
          bare `--warm` warms the connector this directory resolves to, `--warm
          --connector snowflake` warms a named one before any project exists, and `--warm
          explore cluster` warms exactly what that command needs. The last form earns its
          keep because a feature extra resolves into an environment of its own, so
          warming the connector alone leaves `explore cluster` and `explore semantic`
          cold.
          """
      
          extras = _extras(argv)
          spec = _engine_spec(",".join(extras))
          started = time.monotonic()
          # Captured rather than inherited: uv narrates resolution and installation, and
          # stdout here carries one envelope and nothing else.
          completed = subprocess.run(
              _uv_run(spec, ["python", "-c", "import exmergo_dex_core"]),
              capture_output=True,
              text=True,
          )
          elapsed = round(time.monotonic() - started, 3)
          if completed.returncode != 0:
              lines = [line.strip() for line in completed.stderr.splitlines() if line.strip()]
              print(
                  _envelope(
                      "error",
                      errors=[
                          f"warm-up could not install {spec[1]}. uv reported: "
                          + (" ".join(lines[-3:]) or "nothing on stderr")
                      ],
                      reason="prerequisite",
                  )
              )
              return 1
          print(
              _envelope(
                  "ok",
                  data={
                      "engine": spec[1],
                      "connector": extras[0],
                      "extras": extras,
                      "elapsed_seconds": elapsed,
                  },
              )
          )
          return 0
      
      
      def main() -> int:
          argv = sys.argv[1:]
          if shutil.which("uv") is None:
              # The one refusal that happens before the engine exists, which is why the
              # envelope is hand-built. `prerequisite` is the engine's own classification
              # for a missing dependency the user installs and retries (the same one
              # DemoDependencyError and DialectDependencyError carry), so a caller reads
              # this exactly like any other refusal instead of parsing a traceback.
              print(
                  _envelope(
                      "error",
                      errors=[
                          "dex runs its engine through uv, which was not found on "
                          "PATH. Install it with: "
                          "curl -LsSf https://astral.sh/uv/install.sh | sh "
                          "(or `brew install uv`, or `pipx install uv`), then re-run."
                      ],
                      reason="prerequisite",
                  )
              )
              return 1
          # The engine runs in uv's own ephemeral environment, so an inherited
          # VIRTUAL_ENV (e.g. the user's activated venv) is irrelevant to both paths
          # below and only makes uv print a mismatch warning on every call. Drop it.
          os.environ.pop("VIRTUAL_ENV", None)
          if _WARM_FLAG in argv:
              return _warm([arg for arg in argv if arg != _WARM_FLAG])
          # The connector extra is always installed; `explore cluster` and
          # `explore semantic` add what only they need, so the default install stays
          # light for every repo that runs neither.
          cmd = _uv_run(
              _engine_spec(",".join(_extras(argv))),
              ["python", "-m", "exmergo_dex_core", *argv],
          )
          if os.name != "posix":
              # Windows keeps the spawn: exec there hands control back to the shell
              # before the child finishes, and the prompt would interleave with the one
              # envelope a caller is reading off stdout.
              return subprocess.call(cmd)
          # Exec rather than spawn everywhere else: the wrapper has nothing left to do
          # once the engine starts, and replacing the process hands over the terminal,
          # the signals, and the exit code directly instead of relaying them through a
          # parent that is only waiting.
          os.execvp(cmd[0], cmd)
          return 0  # os.execvp does not return; this keeps the signature a plain int
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • SKILL.md 26.5 KB
    ---
    name: explore
    description: 'Use this whenever you need to know what is actually in a database, warehouse, or DuckDB file before you trust it: ranked inventory of what exists, column profiles, PII detection, grain and data-quality problems, verified join inference, Mermaid ER diagrams, guarded ad-hoc SQL probes, k-means segmentation, and reading the semantic layer a repo declares (dbt semantic models, a hosted dbt Cloud layer, or native Apache Ossie documents), producing a draft map without dumping the whole schema into context. Trigger it on an unmet precondition, not on any particular phrasing: if you are about to write or fix SQL against tables whose columns, types, grain, or join keys you have not verified in this session, use this FIRST. That includes dbt work: building a staging or mart model, fixing a broken model, or debugging wrong numbers, whenever the ticket names source tables without spelling out their schema. It also applies mid-task: if you are partway through and hit a table you have not inspected, stop and use this rather than guessing column names or firing off one-off SELECTs. Also use it for direct questions like "what''s in my duckdb", "which tables matter", "how do these tables relate", "is this data any good", "any PII in here", "how many orders have no customer", "cluster my customers", or "what metrics does this semantic layer define". Explore is read-only and writes nothing but the .dex/ cache. It does not author the model: pair it with transform, which writes the change once you know what you are writing against. To reconcile a project that has fallen out of sync, use maintain.'
    ---
    
    # Explore
    
    Make sense of a warehouse or a local DuckDB database the way an analytics
    engineer does: rank what matters, drill selectively, and persist a draft map.
    This is the flagship, fully read-only skill. It absorbs profiling and
    relationship inference as capabilities; they are not separate skills.
    
    ## How to drive it
    
    Run the engine through the wrapper. It prints one sanitized JSON envelope and
    nothing else; read the envelope and decide the next step.
    
    ```bash
    uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" <subcommand> [flags]
    ```
    
    dex runs its engine through `uv`, which is a prerequisite and is not installed by
    Claude Code. If the shell reports `uv: command not found`, stop and tell the user
    to install it (`curl -LsSf https://astral.sh/uv/install.sh | sh`, or
    `brew install uv`, or `pipx install uv`), then re-run. Never fall back to raw
    Python, `pip`, or a database CLI to do the work another way: the guardrails live in
    the engine, so any other path is unguarded.
    
    The first command in a fresh environment installs the engine, so it can take tens
    of seconds where later ones take well under a second. `--warm` pays that install up
    front and exits without running anything:
    
    ```bash
    uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" --warm
    ```
    
    Offer it once at setup. It is not something to run before an ordinary command.
    
    If the user has no warehouse to point at and wants to see what dex does, `demo`
    generates one: a seeded local DuckDB warehouse plus the `.dex/config.yml` for it,
    with no credentials and no network, so every subcommand below then runs with no
    flags. It only ever creates, so it refuses rather than touch a file that already
    exists. Offer it rather than assuming it: a user who does have a warehouse wants
    that one read, not a fixture built beside it.
    
    Subcommands, in the usual order:
    
    1. `connect test --path <file.duckdb>` confirms a read-only connection and
       reports capabilities.
    2. `explore inventory --rank` returns a ranked object summary (counts and sizes,
       never rows).
    3. `explore profile <objects>` (space- or comma-separated) returns column
       profiles, PII flags recorded as (column, category, confidence) and never
       example values, plus ranked candidate keys, the likely grain, `key_evidence`,
       and data-quality warnings (e.g. an id unique on all but 110 rows, which will
       fan out on joins). `candidate_keys` is ordered, tightest proven key first,
       and `key_evidence` gives one entry per combination considered with its
       `status` (`reported` or `suppressed`) and the reason. Read it before you
       trust a composite: a combination unique only because one member is unique on
       almost every row, or because a money column completes it, is suppressed
       rather than reported. Where a near-unique column is the real story the
       warning says so with the ratio, the counts, and how many rows would have to
       be removed for it to be unique. That last number is the one to act on: it
       names a source defect to fix rather than a key to work around. A generic
       `*_name` flag's confidence is refined by value-shape evidence from the same
       scan, in both directions: person-shaped values corroborate it, a closed
       reference vocabulary or long labels de-rate it below the firewall's blocking
       threshold, and missing evidence changes nothing (the flag itself is never
       removed). Distinct counts
       are approximate for scale, but any column that looks unique within
       approximation noise is escalated to an exact COUNT(DISTINCT)
       (`distinct_count_exact: true`), so uniqueness and grain verdicts rest on
       proof; a `~` prefix marks a number that is still approximate, on a count and
       on a percentage alike, so a figure quoted without one is exact arithmetic
       over an exact distinct count on a column with no nulls.
       A requested object whose cached profile is still fresh (same connector,
       schema unchanged, within `profile_freshness_hours`, default 24) is served
       from the cache (`cache_hit_count`) instead of re-scanned, so profiling a
       table `map` just wrote costs nothing to spend; pass `--refresh` to force a
       re-scan when the source changed in a way the free metadata check cannot see.
    4. `explore relationships` returns inferred and declared joins with confidences,
       plus notes explaining what the inference examined (so an empty list is
       meaningful). Add `--verify` to measure each inferred join with an aggregate
       overlap probe (orphan fraction, confidence adjusted). A declared join has two
       sources: a `relationships` test, and (with `--use-project`) an entity two
       semantic models share, which the layer states outright with the key named per
       model. `declared_by` on an edge names that entity, `semantic_join_count` says
       how many came that way, and the notes call out the ones name-based inference
       did not find, which is the interesting set: a semantic layer routinely joins
       columns that share no name at all.
    5. `explore map` writes or updates the `.dex/` cache and returns the map
       (`--verify` works here too). Alongside the counts, `data.objects` gives each
       top-ranked object its row count, detected grain, best-ranked candidate key,
       notable columns (each carrying the role that earned it a place: `grain`, `key`,
       `join`, or a PII flag) and data-quality findings, and `data.edges` gives the
       join edges in the same shape `explore relationships` returns. With
       `--use-project` each object also carries `semantic_models`, the semantic models
       that sit on that relation, which is what separates a load-bearing table from a
       merely large one: empty means nothing in the layer reads it. **Read that
       payload instead of chaining `profile` and `relationships` to re-derive it**;
       go to those two when you need one object in full, or a value domain, which
       `map` never carries. It is budgeted: 25 objects by rank, 12 columns per
       object, 40 edges, 5 findings per object. Every cap binds in every mode and
       every elision is counted in `notes` and in an `elided_*` field, so an empty
       `notes` means nothing was cut. `--detail` widens the selection to every column
       and to objects that were inventoried but never profiled, and lifts no cap; it
       spends nothing, unlike `--full`. Past 50 objects it profiles only the top 25
       by rank and says so in `notes` (with `skipped_count`); pass `--full` to
       profile everything. On a re-map, objects skipped this run keep their prior profiles
       (`carried_forward_count`), each stamped with its own `profiled_at` so
       staleness is visible instead of column detail silently vanishing. A selected
       object whose cached profile is still fresh (same connector, schema unchanged,
       profiled within `profile_freshness_hours`, default 24) is reused without a
       re-scan (`cache_hit_count`), so re-runs cost nothing to spend; pass
       `--refresh` to force a full re-profile when the source changed in a way the
       free metadata check cannot see (e.g. rows changed but the schema did not).
       `explore relationships` and the standalone `explore profile` reuse fresh
       profiles the same way.
    6. `explore diagram [--full]` renders the cached map as a Mermaid ER diagram in
       `data.mermaid`. Free and connectionless (it reads the cache, never the
       warehouse), so it is safe to re-run while shaping the picture. **Reproduce the
       string verbatim in a fenced ```mermaid block so the human can see it, and
       write it to a `.mmd` or a markdown file when they want one on disk: the
       engine deliberately writes no file.** Never redraw or "tidy up" the diagram
       by hand. The glyphs are claims the engine derived from evidence, and a
       plausible-looking cardinality you supplied is exactly the overclaim this
       command exists to prevent: declared joins are solid, inferred dotted, and an
       unverified inference never says "exactly one". A solid line labelled with a
       semantic entity is a join the semantic layer declares; look the entity up with
       `explore semantic list`. Read `notes` before presenting
       it, since it states any object or column that was left out; `--full` widens
       from the default (profiled, joined objects and their grain, key, join, and
       PII columns) to everything eligible.
    7. `explore query "<SELECT ...>" ["<SELECT ...>" ...]` answers ad-hoc questions
       the fixed commands don't cover: you write the SQL, the engine's query firewall
       refuses or bounds it. Pass a statement per argument, or `--sql-file <path>`
       for a longer list, and ask a whole chain of questions in one call rather than
       one call each; each statement is judged and answered on its own, so a refusal
       on one does not cost you the others, and `data.results` carries one entry per
       statement. A table you have not profiled, including a model you just built, is
       profiled for you and the statement then runs, so probing something new is one
       call rather than three; the envelope says what it profiled, and on a metered
       connector that profile is priced into the same confirmation as the statements.
       Results come back row-major and capped; a refusal names the offending column
       and the fix, so one rewrite is enough. Read `${CLAUDE_SKILL_DIR}/references/probe-playbook.md` before
       writing a probe: it maps common questions to effective probe shapes.
    8. `explore cluster <object> [--features a,b,c] [-k N]` runs k-means over a
       bounded sample of the object's numeric columns and returns the segment
       structure: per-cluster sizes and fractions, centroids (each coordinate is a
       cluster's mean of that feature, an aggregate), the silhouette score, and,
       when `-k` is omitted, the k it picked plus the silhouette sweep it chose from.
       Requires the `.dex/` cache (run `map`/`profile` first) so features can be
       auto-selected from profiled numeric, non-PII, non-key columns; pass
       `--features` to choose them yourself (naming a PII column, or a key, opts it
       in deliberately, and only its mean is ever reported). A key is never a
       feature: its mean is meaningless, and a fact table is mostly keys plus a
       handful of measures, so clustering on them just partitions surrogate ranges.
       Keys are the unique columns, the columns that join out (from the joins `map`
       inferred), and the columns named like one; prefer `map` over a bare
       `profile` here, because without inferred joins a foreign key is caught only
       if its name gives it away. The notes name every excluded column, so check
       them before trusting a result. Two things the silhouette alone will not tell
       you, both of which the notes will. A cluster holding under 1% of the sample
       is an outlier pocket, not a segment, and it pushes the score up precisely
       because it sits so far out: report that as outlier detection, or re-run with
       `-k` to split the bulk. And on connectors that cannot seed a sample the draw
       changes per run, so two runs can disagree on k; the envelope's
       `sample_repeatable` says which case you are in, and comparing runs across
       different draws is meaningless. Only aggregates cross the
       boundary: the sample rows are clustered in-process and never enter context.
       On a metered connector it takes the same cost handshake as the scanning
       commands below (only the feature columns are scanned, and a dialect-aware
       sample clause reads a fraction), so surface the estimate and get a budget
       first. Needs the `[cluster]` extra (scikit-learn); the wrapper installs it
       automatically for this subcommand.
    9. `explore semantic list|values|query` reach the semantic layer: the metrics an
       author defined, and the semantic models, measures, dimensions and entities
       they are built out of. Distinct from the warehouse commands above, and from
       the top-level `semantic` group, which *authors* the layer where this *queries*
       it.
    
       `list` is discovery and returns the layer's objects rather than three lists of
       names: semantic models (the unit the layer is organized around, each with the
       transformation model it sits on, its default time dimension, and the physical
       `relation` underneath), metrics (which dimensions each can be grouped by, the
       measures it reads, a ratio's two sides, any filter that makes it a subset, the
       grains it can be queried at, and `time_axis`, the physical time column a time
       grouping resolves to), dimensions (the token to group by, plus the bare
       definition, owning model, queryable grains and `column` behind it), entities
       (one declaration per semantic model, each with its own join key, so the
       declared join graph is readable), and measures (the aggregation and expression
       the number is actually made of, which is often a conditional rather than a
       column). An element defined as an expression carries no column rather than a
       guessed one. So "which table is behind this metric" is the metric's
       `semantic_models` followed to their relations, and `explore profile <relation>`
       is the next call; `--api` exposes no relation at all and declares that in
       `unavailable`, so use `--local` when you need the physical side.
    
       Three free ways to narrow it, and they compose. `--metric <m>` keeps those
       metrics and what they reach. `--for-dimension <d>` asks the reverse question,
       returning the metrics groupable by all the named tokens, which is what you want
       when you know the slice rather than the metric and is also the cheapest way to
       find the metrics that can go on one chart against one axis. `--search <t>`
       takes a word rather than a name and matches it against every element's name and
       against the project's own label and description. Each names its scope in the
       payload (`scoped_to`, `for_dimensions`, `searched_for`), so a subset is never
       mistaken for the layer; an unknown metric or dimension is refused by name,
       while a search term that matched nothing comes back as a note. The catalog is
       also capped, with every cut counted in `elided` and named in `notes` and
       `--full` to lift the caps. `elided` is always present, so all zeros and no cap
       notes is the positive statement that this is the whole layer. Prefer narrowing
       over `--full`: it decides which part comes back rather than letting a cap
       decide.
    
       `values <dimension>` returns that dimension's value domain, which is what you
       need before writing a `--where` filter and the one thing no other dex command
       can reach on a hosted layer (`profile` cannot see a semantic dimension). A
       PII-flagged dimension refuses this command outright rather than being screened,
       because the whole output is values.
    
       `query` takes a positional metric after the explicit mode (with `--metric` kept
       for compatibility), a `--group-by <entity__dim>`, and optional `--where`,
       `--order-by`, `--grain` and `--limit`, and returns the metric's values as a
       capped columnar result. Name flags take a comma-separated list or a repeated
       flag (`--group-by a,b` is `--group-by a --group-by b`); `--where` is never
       split, because a filter clause carries its own commas. `--grain` is checked
       against the grains the layer reports for the metrics queried, so a refusal
       names the ones that metric has.
    
       Two payload fields carry legitimate differences between the backends rather
       than leaving them to be inferred: `dimension_scope` says whether a dimension
       row is one declaration or one groupable path, which is why two backends can
       report different dimension counts for one layer, and `unavailable` names fields
       a backend structurally cannot supply. `--local` resolves the join graph through
       MetricFlow where the `[semantic]` extra is installed, which is what makes its
       dimension lists the tokens a query can actually use; without it the payload says
       `declarations` and a note names the extra.
    
       Three backends answer these commands, chosen by `.dex/config.yml`
       `semantic.vendor` and `semantic.deployment` (the older `semantic.backend`
       spelling still works),
       overridable with `--local` / `--api`. Those two flags name **who executes**, not
       which vendor, and every result reports it as `execution` (`dex` or `vendor`).
       `--local` renders the SQL with MetricFlow and executes it through dex's own
       connector and cost handshake, so cost is surfaced before spend (needs a dbt
       project parsed at least once, and the `[semantic]` extra for `values` and
       `query`; `list` reads the project and needs no extra). `--api` sends the query to
       a hosted dbt Cloud deployment (needs a host, an environment id and a
       `DBT_SL_TOKEN`, plus `[semantic-api]`, and no local project). The hosted backend
       is the one place the cost guard cannot apply: dbt Cloud executes server-side, so
       the result carries an explicit warning that spend is governed there and no
       `--confirm` is asked. Either way a PII-shaped grouped or filtered dimension (for
       example `user__email`) is refused before the query runs, and on `--api` the
       layer's own PII metadata is fetched per metric so a multi-metric query stays
       authoritative rather than falling back to names.
    
       The third backend is `semantic.vendor: ossie`, native Apache Ossie documents
       read out of the repository with no dbt project and no MetricFlow in the path
       (needs the `[ossie]` extra). It is catalog-first: `list` answers, and `values`,
       `query` and `--for-dimension` refuse by name, because Ossie specifies
       interchange metadata and no portable query runtime. Those refusals are the
       format's shape rather than a missing feature, and each one names the physical
       route instead: a dimension carries its `semantic_model`, that model carries its
       `relation`, and `explore profile` then `explore query` reach the values under
       the firewall and the cost guard. `--api` is refused too; Ossie has no hosted
       deployment.
    
       Read `${CLAUDE_SKILL_DIR}/references/semantic-playbook.md` before running a
       metric query: a metric's `time_axis`, `filter` and measures decide what the
       number *is*, and the playbook covers the discovery order, the additivity and
       time-axis traps this surface is full of, when `values` answers rather than a
       query, and what changes when the layer is native Ossie.
    
    Rules of engagement for `query`: prefer the fixed commands when they answer the
    question; one probe answers one question; batch related measures into a single
    query rather than issuing many; aggregates over PII-flagged columns must be
    measuring (COUNT, APPROX_COUNT_DISTINCT, AVG(LENGTH(...))), never value-carrying
    (MIN, ANY_VALUE, STRING_AGG). The FROM clause may unnest JSON and array
    columns in the connector's native idiom, which is the right way to explore
    schemaless data (for example "which keys appear across every row of this JSON
    column"): BigQuery `t, UNNEST(JSON_KEYS(doc)) AS k`, Snowflake
    `t, LATERAL FLATTEN(input => doc) f`, Databricks
    `t LATERAL VIEW EXPLODE(json_object_keys(doc)) x AS k`, Postgres
    `t, jsonb_object_keys(doc) AS k`, Redshift `t, UNPIVOT t.doc AS v AT k`,
    DuckDB `t, UNNEST(json_keys(doc)) AS u(k)`, ClickHouse
    `t ARRAY JOIN JSONExtractKeysAndValuesRaw(doc) AS kv` (there is no lateral
    join; ARRAY JOIN is the expansion). The unnested value must come from
    a column of a table in the query (bare, or through a JSON/array function);
    unnesting a subquery, another table, a literal, or a generator is refused,
    and the unnest's outputs inherit the source column's PII flags. A column whose
    flag was de-rated below the blocking threshold projects normally, with an
    envelope warning naming it; treat the warning as information for the user, not
    an error to fix. If the user says a refused column is not personal data,
    recommend a `pii_overrides` entry in `.dex/config.yml` (fully qualified column,
    optional reason): it unblocks querying immediately, survives re-profiles, and is
    reviewable in git. Never hand-edit `.dex/cache.json` to clear a flag. Never fall
    back to raw Python or a database CLI to run SQL; the firewall path is the only
    sanctioned one.
    
    ## Cloud and database targets (BigQuery, Snowflake, Databricks, Postgres, Redshift, ClickHouse)
    
    A remote warehouse or database replaces `--path` with connector config. Start
    with `connect test --connector <name>` (or set `connector:` plus the matching
    block in `.dex/config.yml`: `bigquery:` with `project` and a `datasets`
    allowlist, `snowflake:` with the pinned `warehouse` and a `databases`
    allowlist, `databricks:` with the pinned SQL `warehouse` and a `catalogs`
    allowlist, `postgres:` with a `schemas` allowlist, `redshift:` with the
    Serverless `workgroup` and a `schemas` allowlist). Credentials are
    discovered, never asked for: if the envelope reports missing or expired
    credentials, relay the fix it names (for BigQuery
    `gcloud auth application-default login`; for Snowflake a `connections.toml`
    entry or `SNOWFLAKE_*` env; for Databricks `databricks auth login` or
    `DATABRICKS_*` env; for Postgres `DATABASE_URL`, `PG*` env, or a
    `pg_service.conf` entry; for Redshift the AWS credential chain
    (`aws configure`, `AWS_*` env) or `REDSHIFT_*` env) and never ask the user to
    paste a key, token, or password.
    
    On a metered connector, scanning commands (`profile`, `map`, `relationships`,
    `query`) run a two-step handshake. The first call returns `needs_confirmation`
    with an estimate in `cost.estimate`, a per-table breakdown where relevant, and
    the unit it is counted in: bytes on BigQuery, warehouse-seconds on Snowflake
    (credits alongside) and Databricks (DBUs), compute-seconds on Redshift
    (RPU-hours), database-seconds on Postgres and ClickHouse (no dollars; the
    guarded quantity is load). Surface the estimate to the user in human units, get
    an explicit budget from them, and re-issue the same command with `--confirm` and
    `--budget <magnitude>` in that unit. Never invent a budget the user did not
    agree to, and never retry with a raised budget on an over-ceiling refusal
    without asking. Metadata is free (`connect test`, `inventory` run immediately),
    and OK envelopes report actual spend under `data.spend`.
    
    An over-ceiling refusal now carries a calibration line drawn from
    `.dex/spend.jsonl`: what this connector's last few settled commands actually
    billed as a fraction of what they were estimated at, or a sentence saying the
    project has too little history to say. On a partitioned or clustered warehouse a
    dry-run estimate is an upper bound, so this is often the difference between a
    budget that admits the work and one that does not. Relay it verbatim when you
    surface the refusal, and note the part callers get wrong: the ceiling is checked
    against the *estimate*, so a budget set at the observed fraction of the estimate
    is refused again. It is still the user's decision, never yours.
    
    When a `needs_confirmation` envelope carries `suggested_session_ceiling`, the
    project has never decided whether the *day's* total spend is bounded, and this is
    the one time it is asked. Surface it beside the per-command estimate and get the
    user's answer: `--session-ceiling <value>` sets a cumulative cap for the project
    (the suggestion is five times this command's estimate, a starting point, not a
    recommendation), and `--no-session-ceiling` records that the project runs
    unbounded. Either one is written to `.dex/config.yml` and reported as a diff, and
    nothing asks again. Add it to the same re-issue that carries `--confirm
    --budget`, or the confirmed run will stop once to ask. Never answer it on the
    user's behalf: it is a durable project setting, not a per-command flag.
    
    On BigQuery a profiling estimate holds a 10 MB floor per table for each
    escalation query a profile may still issue after its aggregate scan, so on a
    warehouse of many small tables most of the number can be reserve for work that
    never happens. Both the handshake and the over-ceiling refusal report that split
    (`reserved_bytes` and `reserved_queries`, and in the prose). Pass it on when you
    surface the estimate: whether a number is scan or reserve changes whether
    raising the budget is buying work or headroom.
    
    When an estimate is larger than the work deserves, narrow the scope rather than
    raise the budget. `--scope` (repeatable) bounds a command to part of the
    configured source allowlist, in the connector's own vocabulary: a dataset on
    BigQuery, a `schema` or `database.schema` on Snowflake, a `catalog.schema` on
    Databricks, a schema on Postgres or Redshift, a database on ClickHouse (whose
    identifiers are two-part `database.table`: there is no catalog level). It is
    free to resolve, it can only narrow what
    `.dex/config.yml` already allows, and a scope that names nothing is refused with
    the schemas that do exist listed. So `explore map --scope <schema>` is the first
    thing to reach for on a warehouse whose full map would be expensive.
    
    ## Guardrails (enforced in the engine, not here)
    
    - Read-only against data. The connection is opened read-only and generated SQL is
      SELECT-only. Never propose a write to source data.
    - Sense-making, not enumeration. Rank and drill selectively; never paste a full
      schema into context.
    - Profile, don't exfiltrate. Understanding comes from aggregates. PII is flagged,
      never surfaced, and the query firewall enforces it on your own SQL: values
      cross the envelope only from profiled columns whose flag is absent or below
      the blocking threshold, bounded and capped. Only a human's `pii_overrides`
      entry clears a flag entirely; never suggest weakening the detection.
    - The two policies in full, in the engine repository:
      `references/pii-policy.md` and `references/cost-controls.md`.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related