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 " 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
Resolve the plugin root. Every bash snippet below assumes
$plugin_rootis set in that shell invocation; re-run this line whenever you start a new shell: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'splugin/.$plugin_pythonis 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 topython3if the venv doesn't exist yet.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 tostaircase.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 tostaircase.py(step 4) as--join-id-key <value>.
If the file is missing, tell the user to run the init flow first (
/agentic-analytics:initon 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-idflag tostaircase.py. - If non-empty: filter events to that site. Pass
--site-id <value>tostaircase.py. The site ID itself shows up in the report'sSite ID filter:line, so the report stays identifiable without a separate header label.
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 - 30dwindow 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-cacheskill 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:# 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 < 30after the pull attempt, build a one-line coverage caveat string and pass it as--data-coverage-notein 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: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." fiAuto-detect employee traffic on first populated cache (silent). If
employee_filter_checkedis absent frombucket.json, run the detection script in quiet mode now that the pull has had a chance to populate the lake. The script writesemployee_filter_checked: trueafter scanning so this only runs once, and writesemployee_filteronly if it finds an unambiguous match. On match, the report's own one-line note (rendered bystaircase.pywhen--employee-filter-*is passed) is what surfaces the result – this step adds no chat output of its own.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 fiPass
--site <site-id>only when the site ID filter is non-empty (single-site mode); omit it in all-sites mode.The
|| trueis intentional: detection failures (empty lake, transient I/O) should never block the report. Re-readbucket.jsonafter this step so any newly-writtenemployee_filteris available for step 4.Run the report. Slug the output filename from
cache_dirplus the site ID filter (orallwhen none):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-dirand--cache-rootare NOT passed;staircase.pyopens the catalog and reads events from the lake internally.--site-labelis 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-labeland--network-sourcesare 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/staircasewrapper) or future plugin config; the public init flow doesn't set them.--internal-domainsonly applies when the wrapper supplies a corp domain or the user has configured one. Plain customer installs typically omit it.--employee-filter-keyand--employee-filter-valueare paired: pass both or neither. Read frombucket.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 becomestrueorfalse, a string gets wrapped in quotes (e.g.'"yes"'with single-quoted shell escaping), and numbers stay bare (1).staircase.pydecodes it withjson.loadsand compares with==againstextra_data[<key>]. When set, matching events are excluded from the report and a one-line note appears at the top.Pass
--site-idonly when the site ID filter is non-empty. Pass--data-coverage-noteonly when$noteis non-empty (i.e. windows are short). Pass--employee-filter-*only whenemployee_filteris present inbucket.json. Pass--join-id-keyonly whenjoin_id_keyis present inbucket.json(this flag is plumbing between the skill and the script; customers don't type it).Output the report: read
/tmp/staircase-$slug.mdand emit its contents verbatim. Don't add formatting, don't add framing.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
.htmlfile — same$slugas step 4, with the.htmlextension. Do not link the.mdfile: that's the report body you just emitted verbatim in step 5, not the HTML version. Thefile://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-noteso 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.jsonis missing, tell the user to run the init flow first; do not try to guess values. - The trailing link points at the
.htmlfile from step 4 (--output-html), never the.mdfile. - If the lake has no DPL events for the configured bucket/site, surface that as an error (one short line) and stop.