# Staircase Report

> Run the staircase audience-relationship report for a customer. Use when the user asks to run the staircase report, requests it for a specific site, or asks for an audience-tier or relationship-intelligence report.

- Skill: `automattic/staircase-report` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add automattic/staircase-report`
- Raw SKILL.md: https://api.skillmd.com/api/skills/automattic/staircase-report/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: Automattic (https://skillmd.com/u/automattic)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/automattic/staircase-report

---


Run the staircase audience-relationship report.

The customer may supply an optional **site ID filter** (via a slash-command argument on harnesses that have them, or in plain chat: "run the staircase report for your-site.com"). Empty means "run across all sites in the configured bucket"; non-empty means "filter to that one site (e.g. `your-site.com`)."

**Output ONLY the report contents** (the markdown body of the generated report file) followed by a single `file://` link line at the end. NO preamble. NO postscript. NO commentary. NO explanation. The Recommendations section at the top of the report IS the analysis. Don't re-narrate it.

You are a pipe, not an analyst. The customer reads the report directly.

**This rule applies to conversational invocations too.** When the user asks for "the staircase report" / "run the report" / "run the staircase report for <site-id>" without typing a slash command, follow the same contract: render the markdown report verbatim in chat, then the `file://` HTML link, no narration. A summary instead of the report is the wrong shape. The user wants to read the report in the terminal. The HTML version is allowed to carry more detail than the in-chat markdown (e.g., the collapsible full-tail referrer list); that asymmetry is intentional, don't try to mirror everything into the chat output.

## Steps

0. **Resolve the plugin root.** Every bash snippet below assumes `$plugin_root` is set in that shell invocation; re-run this line whenever you start a new shell:

   ```bash
   plugin_root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-<plugin-root>}}"
   plugin_python="${AGENTIC_ANALYTICS_VENV:-${AGENTIC_ANALYTICS_DATA_DIR:-$HOME/.local/share/agentic-analytics}/venv}/bin/python"
   [ -x "$plugin_python" ] || plugin_python="python3"
   ```

   `<plugin-root>` is the plugin's install directory – the directory two levels above this SKILL.md file. In a dev checkout of the source repo that's `plugin/`. `$plugin_python` is the plugin's isolated venv (created by the init flow at `$AGENTIC_ANALYTICS_DATA_DIR/venv`, default `~/.local/share/agentic-analytics/venv`); it falls back to `python3` if the venv doesn't exist yet.

1. **Load the bucket config and resolve the site ID filter.** Read `${XDG_CONFIG_HOME:-~/.config}/agentic-analytics/bucket.json`, written by the init flow. It contains:

   - `bucket`: S3 bucket holding the customer's DPL events.
   - `profile`: AWS profile to authenticate with.
   - `cache_dir`: a slug used to identify the customer's data and name output files (e.g. `acme`).
   - `employee_filter` (optional): `{ "extra_data_key": "...", "extra_data_value": ... }`. Written by the auto-detection in step 3 (or a manual run of the identify-employees skill) when an unambiguous employee-traffic tag was found. If present, pass it through to `staircase.py` (step 4); the report excludes those events and renders a one-line note up top.
   - `employee_filter_checked` (optional): boolean flag set after employee-traffic detection has run at least once against a populated cache. Step 3 below uses this to decide whether to run a first-time auto-detect.
   - `join_id_key` (optional): the URL query parameter the customer carries in email-campaign links to identify each recipient (e.g. `"pid"`). When set, cohort CSVs gain a column with that name, populated from the query string of any pageview where the parameter appears. The customer joins the cohort back to their CRM list on that column. If present, pass it through to `staircase.py` (step 4) as `--join-id-key <value>`.

   If the file is missing, tell the user to run the init flow first (`/agentic-analytics:init` on harnesses with slash commands, or "set up agentic analytics" in chat) and stop.

   The site ID filter:

   - If empty: the report runs across every site in the bucket. Pass NO `--site-id` flag to `staircase.py`.
   - If non-empty: filter events to that site. Pass `--site-id <value>` to `staircase.py`. The site ID itself shows up in the report's `Site ID filter:` line, so the report stays identifiable without a separate header label.

2. **Top up the cache, then compute windows from what's actually on disk.** A customer's cache may start well after the date the report was requested. Using a fixed `today - 30d` window can land the prior window entirely before the data starts, producing a report where every prior count is 0 and every row is labeled "new". Always anchor on what's on disk.

   Follow the `update-cache` skill to pull the latest data into the local lake. The pull is incremental, so already-synced customers pay milliseconds. If the pull fails (auth lapse, S3 hiccup), fall through to whatever is in the lake.

   Then probe the catalog for the earliest and latest day available, split the available span into two equal-size windows. In single-site mode filter by `site`; in all-sites mode query the whole lake:

   ```bash
   # Single-site catalog probe:
   read earliest latest < <(python3 -c "
   import sys; sys.path.insert(0, '$plugin_root/scripts/db/lib')
   import catalog
   con = catalog.connect()
   catalog.ensure_view(con, 'dpl', 'events')
   row = con.execute(\"SELECT min(day), max(day) FROM dpl_events WHERE apikey = '<site-id>'\").fetchone()
   if row[0] is None: sys.exit(1)
   print(row[0], row[1])
   ")

   # All-sites catalog probe:
   read earliest latest < <(python3 -c "
   import sys; sys.path.insert(0, '$plugin_root/scripts/db/lib')
   import catalog
   con = catalog.connect()
   catalog.ensure_view(con, 'dpl', 'events')
   row = con.execute('SELECT min(day), max(day) FROM dpl_events').fetchone()
   if row[0] is None: sys.exit(1)
   print(row[0], row[1])
   ")

   if [ -z "$earliest" ]; then echo "ERROR: no data found in catalog for the requested site/bucket" >&2; exit 1; fi

   # Compute window in Python for cross-platform date arithmetic.
   # Prefer 30/30; fall back to N/N where 2N fits the available span.
   read available window current_start current_end prior_start prior_end < <(python3 -c "
   from datetime import date, timedelta
   import sys
   earliest = date.fromisoformat('$earliest')
   latest = date.fromisoformat('$latest')
   available = (latest - earliest).days + 1
   window = 30 if available >= 60 else available // 2
   if window < 1:
       sys.stderr.write(f'ERROR: not enough catalog coverage (only {available} day(s))\n')
       sys.exit(1)
   current_end = latest
   current_start = latest - timedelta(days=window - 1)
   prior_end = latest - timedelta(days=window)
   prior_start = prior_end - timedelta(days=window - 1)
   print(available, window, current_start, current_end, prior_start, prior_end)
   ")
   if [ -z "$window" ] || [ "$window" -lt 1 ]; then exit 1; fi
   echo "available=$available days  window=$window  current=$current_start..$current_end  prior=$prior_start..$prior_end"
   ```

   If `window < 30` after the pull attempt, build a one-line coverage caveat string and pass it as `--data-coverage-note` in the next step. It renders into the report markdown and HTML so it travels with shared reports. Don't add chat-only narration on top. Example:

   ```bash
   note=""
   if [ "$window" -lt 30 ]; then
     note="Only $available days of data were available, so windows are ${window}/${window} days instead of the usual 30/30. Trends from shorter windows are noisier."
   fi
   ```

3. **Auto-detect employee traffic on first populated cache (silent).** If `employee_filter_checked` is absent from `bucket.json`, run the detection script in quiet mode now that the pull has had a chance to populate the lake. The script writes `employee_filter_checked: true` after scanning so this only runs once, and writes `employee_filter` only if it finds an unambiguous match. On match, the report's own one-line note (rendered by `staircase.py` when `--employee-filter-*` is passed) is what surfaces the result – this step adds no chat output of its own.

   ```bash
   if ! python3 -c "import json,sys; sys.exit(0 if json.load(open('${XDG_CONFIG_HOME:-$HOME/.config}/agentic-analytics/bucket.json')).get('employee_filter_checked') else 1)" 2>/dev/null; then
     "$plugin_python" "$plugin_root/skills/identify-employees/scripts/detect_employee_filter.py" \
       --bucket-config "${XDG_CONFIG_HOME:-$HOME/.config}/agentic-analytics/bucket.json" \
       [--site <site-id>] \
       --quiet || true
   fi
   ```

   Pass `--site <site-id>` only when the site ID filter is non-empty (single-site mode); omit it in all-sites mode.

   The `|| true` is intentional: detection failures (empty lake, transient I/O) should never block the report. Re-read `bucket.json` after this step so any newly-written `employee_filter` is available for step 4.

4. **Run the report.** Slug the output filename from `cache_dir` plus the site ID filter (or `all` when none):

   ```bash
   slug="<cache_dir>$([ -n "<site-id>" ] && echo "-<site-id>" || echo "-all")"
   "$plugin_python" "$plugin_root/skills/staircase-report/scripts/staircase.py" \
     [--site-id <site-id>] \
     --current <current_start> <current_end> \
     --prior <prior_start> <prior_end> \
     [--internal-domains <corp-domain>] \
     [--network-label "<network-label>" --network-sources "<network-sources>"] \
     [--data-coverage-note "$note"] \
     [--employee-filter-key <key> --employee-filter-value <json-value>] \
     [--join-id-key <key>] \
     --output-md /tmp/staircase-$slug.md \
     --output-html /tmp/staircase-$slug.html
   ```

   `--events-dir` and `--cache-root` are NOT passed; `staircase.py` opens the catalog and reads events from the lake internally.

   `--site-label` is omitted in the public flow – reports just say "Relationship Staircase Report" at the top. Callers that want a labeled header (the dev wrapper) can still pass `--site-label "<value>"`.

   `--network-label` and `--network-sources` are paired: pass both or neither. They classify cross-promotion traffic from the customer's own properties under a named channel. Source values for the customer come from the wrapper (when invoked via the dev `/staircase` wrapper) or future plugin config; the public init flow doesn't set them.

   `--internal-domains` only applies when the wrapper supplies a corp domain or the user has configured one. Plain customer installs typically omit it.

   `--employee-filter-key` and `--employee-filter-value` are paired: pass both or neither. Read from `bucket.json["employee_filter"]` when present (step 1). The key passes through as-is. The value must be JSON-encoded on the command line: a Python boolean becomes `true` or `false`, a string gets wrapped in quotes (e.g. `'"yes"'` with single-quoted shell escaping), and numbers stay bare (`1`). `staircase.py` decodes it with `json.loads` and compares with `==` against `extra_data[<key>]`. When set, matching events are excluded from the report and a one-line note appears at the top.

   Pass `--site-id` only when the site ID filter is non-empty. Pass `--data-coverage-note` only when `$note` is non-empty (i.e. windows are short). Pass `--employee-filter-*` only when `employee_filter` is present in `bucket.json`. Pass `--join-id-key` only when `join_id_key` is present in `bucket.json` (this flag is plumbing between the skill and the script; customers don't type it).

5. **Output the report**: read `/tmp/staircase-$slug.md` and emit its contents verbatim. Don't add formatting, don't add framing.

6. **Append the HTML link** as the last line, with one blank line separating it from the report body:

   ```
   [Open the HTML version in browser](file:///tmp/staircase-$slug.html)
   ```

   The link target is the **`.html`** file — same `$slug` as step 4, with the `.html` extension. Do **not** link the `.md` file: that's the report body you just emitted verbatim in step 5, not the HTML version. The `file://` protocol matters: a bare path can be intercepted by an editor.

## Constraints (re-stated for emphasis)

- No "Running staircase for…" preamble.
- No "Two things stand out…" / "Let me know if…" postscript.
- No re-narration of the report's findings.
- The pull-then-probe step in §2 handles short caches. Pass the caveat via `--data-coverage-note` so it lands inside the report itself (visible in both the in-chat verbatim render and the shared HTML). Don't add chat-only narration on top of that.
- If `bucket.json` is missing, tell the user to run the init flow first; do not try to guess values.
- The trailing link points at the `.html` file from step 4 (`--output-html`), never the `.md` file.
- If the lake has no DPL events for the configured bucket/site, surface that as an error (one short line) and stop.

