{"owner":"antiwork","repo":"gumroad","hasSkills":true,"totalSkillsCount":16,"totalTokensCount":8990,"categories":["anthropic-skill","root-instruction","claude-rule","copilot-instructions"],"hasMcp":false,"mcpConfig":null,"found":[".agents/skills/gumroad-prod-console/SKILL.md",".agents/skills/email-blast/SKILL.md",".agents/skills/review-pr/SKILL.md",".agents/skills/create-issue/SKILL.md",".agents/skills/commit/SKILL.md",".agents/skills/test-confidence/SKILL.md","AGENTS.md",".claude/skills/create-issue",".claude/skills/email-blast",".claude/skills/gumroad-prod-console",".github/copilot-instructions.md",".claude/skills/commit",".claude/skills/review-pr",".claude/skills/test-confidence",".agents/skills/gumroad-prod-console/references/common-queries.md",".agents/skills/review-pr/references/review-guidance.md"],"skills":{".agents/skills/gumroad-prod-console/SKILL.md":"---\nname: gumroad-prod-console\ndescription: >\n  Execute read-only Ruby/Rails commands against Gumroad's production database for debugging\n  and investigation. Use when the user needs to debug production issues, look up data,\n  investigate user reports, check records, or query production state. Triggers on: \"check in\n  prod\", \"debug this in prod\", \"look up user/purchase/product in production\", \"production\n  console\", \"investigate in prod\", \"query production\", \"what's happening in prod\", \"look up\n  a user by email\", \"who bought this product\", \"why is this user blocked\", \"find this\n  sale/purchase\", \"how many X does Y have\", or any request to examine live Gumroad data —\n  even when the user doesn't say \"prod\" explicitly.\n---\n\n# Gumroad production console\n\nRead-only Rails runner against the production read replica via bastion SSH.\n\n## Execution\n\n```bash\n.claude/skills/gumroad-prod-console/scripts/prod_query.sh 'puts User.count'\n.claude/skills/gumroad-prod-console/scripts/prod_query.sh /tmp/query.rb\necho 'puts User.count' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\n```\n\nMulti-line queries: write a temp `.rb` file, pass its path. Bash tool timeout is ~120s — wrap slow queries in `WithMaxExecutionTime.timeout_queries(seconds: 30) { ... }`. Queries run against the read replica (`DATABASE_WORKER_REPLICA1_HOST` by default).\n\nFollow-up queries are cheap — start scoped (one record, one field), then drill in as the investigation clarifies. Avoid the urge to return everything in a single large query.\n\nWarm hops: `prod_query.sh` multiplexes SSH to the bastion (`ControlPersist`) and reuses the last-good instance IP for 10 minutes (`PROD_IP_CACHE_TTL`). A second query should skip EC2 discovery. Pin with `PROD_INSTANCE_IP` to skip it entirely. Tracked in gumroad-private#2197.\n\nPersistent runner: the first replica-default query on a host boots a long-lived `rails runner` loop (`scripts/prod_runner_loop.rb`) inside the puma container; later queries are spooled to it and skip Rails boot entirely (~1s warm vs ~14s one-shot). The loop forks per query, keeps stdout/stderr separate, idles out after 30 minutes without work, and is replaced automatically when the local `prod_runner_loop.rb` changes. Non-default `PROD_DB_HOST_VAR` sessions (primary writes) always take the one-shot path; `PROD_NO_RUNNER_LOOP=1` opts out.\n\n## Safety\n\n- **Read-only.** Never write, update, or delete.\n- **No Redis `KEYS` or `FLUSH*`.** `prod_query.sh` prepends `scripts/redis_command_guard.rb`, which refuses them: one `KEYS` over the ~4.6M-key primary blocks it for seconds and 500s the site. Use `$redis.scan_each(match:, count: 1000).first(n)`.\n- Always `.limit()` / `.first()` / `.take()` — never unbounded result sets.\n- Prefer `.pluck(:col, :col)` over loading full AR objects.\n- Mask PII in output (truncate emails, addresses, payment details).\n- `.explain` before querying large tables without indexed conditions.\n- Emit structured output (JSON for complex, `.inspect` for simple) so results are parseable.\n\n## Gumroad-specific gotchas\n\n### External IDs, not primary keys\n\nAdmin URLs and public IDs use **external IDs** (`ExternalId` module — Base64 strings like `aBcDeFgHiJkLmNoPqRsTuQ==`), not integer PKs.\n\n```ruby\nPurchase.find_by_external_id(\"aBcDeFgHiJkLmNoPqRsTuQ==\")  # correct\nPurchase.find(\"aBcDeFgHiJkLmNoPqRsTuQ==\")                  # WRONG — treats it as a PK, silently returns the wrong record\n```\n\n### Model naming\n\n| Model | Note |\n|---|---|\n| `Link` | The product model (legacy name). `find_by(unique_permalink:)` for permalinks; `alive` scope for non-deleted. |\n| `Installment` | Subscriptions/recurring (not ActiveRecord's sense of \"installment\"). `where(link_id:, alive: true)`. |\n| `Comment` | Admin notes on records. `content` field, **not** `body`. |\n| `MerchantAccount` | Processor-specific — users can have multiple. `where(user_id:, charge_processor_id:)`. |\n| `Purchase` | `successful` scope for completed sales. `where(email:)`, `where(link_id:)`. |\n\n`User`, `Balance`, `Dispute`, `Follower`, `CustomDomain` behave as names suggest.\n\n### Sidekiq queues\n\n`critical` (12k limit — payouts, webhooks, receipts) → `default` (300k — general) → `long` (PDF stamping etc.) → `low` (expiry). Queue limits at `app/controllers/healthcheck_controller.rb:28`; worker ordering at `docker/web/sidekiq_worker.sh`.\n\n### DevTools (Gumroad-specific helpers)\n\nSee `lib/utilities/dev_tools.rb`:\n\n- `DevTools.reindex_all_for_user(user_id)` — reindex ES data\n- `DevTools.reimport_follower_events_for_user!(user)` — reimport follower analytics\n\nFor Gumroad-specific scopes and associations (`alive`, `successful`, `unpaid_balance_cents`, `payments`, `products`, Flipper checks, Sidekiq introspection), see [references/common-queries.md](references/common-queries.md).\n\n### Stale bastion host keys look like unhealthy instances\n\nEC2 recycles private IPs, so the **bastion's** `known_hosts` accumulates stale keys and refuses the onward hop with `REMOTE HOST IDENTIFICATION HAS CHANGED` / `Offending ECDSA key`. From outside this is easy to mistake for a hung instance, and it silently shrinks the usable pool each time instances are replaced.\n\n`prod_query.sh` catches them without touching the pin: a candidate is recorded as stale-keyed when a changed-key complaint names its address — including on a probe that looked healthy, since the forced command exits 0 after its own hop refused — and the run reports those addresses with the one-hop `ssh-keygen -R` command to run by hand.\n\n- **The pin is never removed automatically.** Every textbook trigger for a removal is something a pin exists to defend against: EC2 membership says the address is a pool member, not that the key it serves is the instance's, and a changed-key complaint is an alarm rather than proof of a recycle — deleting the entry and reconnecting pins whatever answered. Clearing is an explicit operator action, printed as a one-hop command.\n- **Only genuinely outdated keys are reported.** A plain timeout, a container that is still starting, or a network blip leaves the recorded key alone — only SSH naming that address counts. The onward hop usually just *warns* about a changed key and connects anyway (recycled IPs make that the steady state), so a warn-and-proceed failure stays on the patient-retry list too. Only an outright `Host key verification failed` refusal drops a candidate from the retry.\n- **A pool rejected entirely for stale keys says so**, with that same command, instead of the generic health-probe error.\n\n- **`LC_PAPER=127.0.0.1` means the bastion itself.** The forced command jumps to whatever `LC_PAPER` names, and omitting the variable — or the `-o SendEnv=LC_PAPER` that ships it — fails with `ssh: Could not resolve hostname`; instance IPs run the command on that instance. Anything aimed at the bastion's own files goes through `127.0.0.1`.\n- **The warning text is often noise.** A run can print the whole man-in-the-middle banner for a *previous* hop and still succeed. Judge by exit code and `MARK` output, not the banner. `rc 255` is the transport dropping mid-flight, so the outcome is unknown — do not read it as a failed query. Any *other* nonzero rc whose only output is the banner means the query failed: look for `Query process killed by SIGKILL` or the query's own output.\n\n### \"No instance passed the health probe\" usually means slow, not down\n\nThe candidate probe is deliberately impatient (20s) so one hung host cannot eat the caller's budget, but that also rejects hosts which are merely slow under load. When *every* candidate fails, a fleet-wide outage is the less likely reading.\n\n`prod_query.sh` now retries the non-stale-key rejections with a patient probe before giving up, and says so (`answered on the patient retry (slow, not unhealthy)`). Only the all-rejected path pays for this.\n\nBoth passes draw down one shared **90s selection budget** (`PROD_SELECT_BUDGET`), so picking a host can never consume the caller's whole ~120s Bash-tool window before the query starts — a large pool shortens each probe rather than adding to the total. A third of the budget is held back for the patient pass, so a pool of slow hosts cannot drain everything in the fast pass and starve the retry. Running out gives a distinct message that says the pool may just be slow, instead of the misleading \"no instance passed the health probe\". Raise the budget when you have more wall clock than the Bash tool allows:\n\n```bash\nPROD_SELECT_BUDGET=240 timeout 560 bash .agents/skills/gumroad-prod-console/scripts/prod_query.sh q.rb\n```\n\nIf you still get the hard error, force a host rather than assuming production is down:\n\n```bash\nPROD_INSTANCE_IP=10.1.34.180 bash .agents/skills/gumroad-prod-console/scripts/prod_query.sh q.rb\n```\n\nThis matters for **watchers**, not just interactive use: a cron that treats an unreadable console as \"nothing to report\" goes silent indefinitely and looks healthy. On 2026-07-29 all 8 candidates were rejected while a forced host answered the same query in 13s.\n\n### Grepping only for `^MARK` can hide a total failure\n\n`... | grep \"^MARK\"` on a run that never reached Rails returns empty with **exit 0**, which reads as \"query worked, no rows\". Capture to files and echo the exit code:\n\n```bash\ntimeout 560 prod_query.sh /tmp/q.rb > /tmp/q.out 2>/tmp/q.err; echo \"exit=$?\"\ngrep -a \"^MARK\" /tmp/q.out\n```\n\nExit `124` = outer timeout (query too heavy). `255` = SSH itself failed: either the hop dropped before Rails started, or the transport dropped mid-flight with the spool state unknown — treat the query's outcome as unknown rather than failed.\n\n### `association.unscoped` reads the whole table\n\n`Link` has no `default_scope`, so `user.links` already includes deleted rows — there is nothing to reach for `.unscoped` for. On an association it drops the `user_id` condition instead, leaving `SELECT * FROM links` (millions of rows). Iterating that gets the query process OOM-killed: the run exits nonzero, prints `Query process killed by SIGKILL`, and the stale host-key banner above it is **not** the cause. Use `user.links` (or `Link.where(user_id: user.id)`).\n\n### Keep Stripe API calls out of loops\n\nThe DB is fast; `Stripe::Transfer.list` / `Charge.retrieve` per record is what blows the timeout — even ~5 accounts with one Stripe call each can hit exit `124`. Use raw SQL via `ActiveRecord::Base.connection.select_all` for population questions, and fetch at most 1–2 Stripe objects per run when you need live processor state.\n\n## Requirements\n\n- An AWS profile with `ec2:DescribeInstances` on the prod account. The script defaults to the profile `gumroad-prod` (created by `scripts/setup.sh`). Override by exporting `AWS_PROFILE` or setting `PROD_AWS_PROFILE` in your config file.\n- SSH access to your production bastion (defaults to `bastion-production.gumroad.net`)\n\n### Gumroad team one-time setup\n\nIf you previously ran this script via `GUMROAD_DEPLOYMENT_DIR` and `.env.aws`, run the helper from the repo root to migrate those creds into an AWS CLI profile:\n\n```bash\n.claude/skills/gumroad-prod-console/scripts/setup.sh\n# or pass an explicit path:\n.claude/skills/gumroad-prod-console/scripts/setup.sh ~/path/to/.env.aws\n```\n\nThe helper will also offer to append `export AWS_PROFILE=gumroad-prod` to your shell profile — say yes and reload the shell. After this, `gumroad-private` is no longer required to run the skill.\n\n## Configuration (self-hosters)\n\nDefaults target Gumroad's prod infra. If you're running your own Gumroad fork, override by creating `~/.config/gumroad-prod-console.env`:\n\n```bash\nPROD_BASTION=bastion.mycompany.com\nPROD_SECURITY_GROUP=my-web-sg\nPROD_CONTAINER_FILTER=app-*\nPROD_DB_HOST_VAR=MY_READ_REPLICA_HOST\nPROD_AWS_PROFILE=my-aws-profile\n```\n\nOr export the same variables before invoking the script.\n",".agents/skills/email-blast/SKILL.md":"---\nname: email-blast\ndescription: >\n  Send one-off email blasts to Gumroad creators directly via production console, no PR or\n  deploy needed. Handles drafting, recipient query, count validation via Metabase, dry-run\n  preview, and execution. Use when asked to \"email creators\", \"send an announcement\",\n  \"blast sellers\", \"notify users\", or any request to send emails to Gumroad creators at scale.\n---\n\n# Email Blast\n\nSend one-off emails to Gumroad creators by executing Ruby inline against the production console. No PR, no deploy, no service class needed.\n\n## Prerequisites\n\n- Production console access configured via the `gumroad-prod-console` skill (see its SKILL.md for setup)\n- Metabase API key (in 1Password: \"Metabase (Gumroad)\" → \"API Key\")\n\nAll production Ruby execution in this skill goes through `.claude/skills/gumroad-prod-console/scripts/prod_query.sh`. That script targets the read replica for MySQL, which is sufficient here — `User.alive.not_suspended...find_each` is a read, and `deliver_later` + `$redis.set` hit Redis, not MySQL. Do not add any MySQL-write operations to blast scripts.\n\n## Workflow\n\n### 1. Clarify the request\n\nFrom the human's request, extract:\n- **Who** — recipient criteria (e.g. \"all active creators\", \"creators who earned > $1000\")\n- **What** — the message to send (subject + body)\n- **Reply-to** — default `support@gumroad.com`\n\n### 2. Draft the email\n\nWrite a `subject` and HTML `body`:\n- Simple HTML only: `<p>` tags, `<a>` links\n- Sign off: `<p>— The Gumroad Team</p>`\n- No `<ul>`/`<li>` (renders poorly). Use `<p>• Item</p>` for bullets.\n- Keep it short and direct\n- **Always use** `from: \"Gumroad <gumroad@creators.gumroad.com>\"` and `sender_domain: :creators`\n- The OneOffMailer template renders the subject as an `<h2>` heading at the top automatically\n\n**Present the draft for human approval before proceeding.**\n\n### 3. Count recipients via Metabase\n\nBuild a SQL query and validate the count:\n```bash\nMETABASE_KEY=$(op item get \"Metabase (Gumroad)\" --vault Gumclaw --fields \"API Key\" --reveal)\ncurl -s -X POST \"https://gumroad.metabaseapp.com/api/dataset\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-Metabase-Session: $METABASE_KEY\" \\\n  -d '{\n    \"database\": 2,\n    \"type\": \"native\",\n    \"native\": { \"query\": \"SELECT COUNT(*) FROM users WHERE deleted_at IS NULL AND is_suspended = 0 AND ...\" }\n  }' | python3 -c \"import json,sys; print(json.load(sys.stdin)['data']['rows'][0][0])\"\n```\n\n**Always filter**: `deleted_at IS NULL AND is_suspended = 0` (alive, not suspended).\n\nShow the count to the human before executing.\n\n### 4. Dry run (preview)\n\nGenerate a script that sends to just 1-2 test recipients first. Run via `prod_query.sh`:\n```bash\ncat << 'RUBY' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\ntest_user = User.find_by(email: \"sahil@gumroad.com\")\nOneOffMailer.email(\n  email: test_user.form_email,\n  from: \"Gumroad <gumroad@creators.gumroad.com>\",\n  subject: \"SUBJECT HERE\",\n  body: <<~HTML,\n    <p>Body here.</p>\n    <p>— The Gumroad Team</p>\n  HTML\n  reply_to: \"support@gumroad.com\",\n  sender_domain: :creators\n).deliver_later(queue: \"low\")\nputs \"Test email enqueued to #{test_user.form_email}\"\nRUBY\n```\n\n**Wait for human confirmation that the test email looks good before proceeding.**\n\n### 5. Execute the blast\n\nGenerate the full script with Redis checkpointing for resumability:\n\n```bash\ncat << 'RUBY' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\nredis_key = \"email_blast_DESCRIPTIVE_KEY_last_user_id\"\nlast_processed_id = $redis.get(redis_key).to_i\ncount = 0\n\nUser.alive.not_suspended.where(\"id > ?\", last_processed_id).where(CRITERIA).find_each(batch_size: 500) do |user|\n  next if user.form_email.blank?\n\n  OneOffMailer.email(\n    email: user.form_email,\n    from: \"Gumroad <gumroad@creators.gumroad.com>\",\n    subject: \"SUBJECT\",\n    body: <<~HTML,\n      <p>Body here.</p>\n      <p>— The Gumroad Team</p>\n    HTML\n    reply_to: \"support@gumroad.com\",\n    sender_domain: :creators\n  ).deliver_later(queue: \"low\")\n\n  $redis.set(redis_key, user.id)\n  count += 1\n  Rails.logger.info \"Enqueued email for user #{user.id} (#{count} total)\" if count % 100 == 0\nend\n\nputs \"Done. Enqueued #{count} emails.\"\nRUBY\n```\n\n### 6. Resume / Reset\n\n- **Resume** (if interrupted): Just re-run the same script. Redis checkpoint skips already-processed users.\n- **Reset** (to re-send): Delete the Redis key first:\n  ```bash\n  echo '$redis.del(\"email_blast_DESCRIPTIVE_KEY_last_user_id\")' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\n  ```\n\n## Safety Rules\n\n1. **Always get human approval** on subject, body, and recipient count before executing\n2. **Always send a dry-run** to a test recipient first (sahil@gumroad.com or the requester)\n3. **Always use Redis checkpointing** — blasts can be interrupted and resumed\n4. **Always use `deliver_later(queue: \"low\")`** — never `deliver_now` (would block the runner)\n5. **Always filter** `alive.not_suspended` and `form_email.present?`\n6. **Never send from** `hi@customers.gumroad.com` — that's for transactional emails only\n7. **Log progress** so you can verify emails are being enqueued\n\n## Key References\n\n- `OneOffMailer`: `app/mailers/one_off_mailer.rb`\n- Example service: `app/services/onetime/notify_sellers_about_legacy_fee_migration.rb`\n- `form_email`: The user's preferred contact email (may differ from login email)\n- Sender domains: `:creators` → `creators.gumroad.com`, default → `customers.gumroad.com`\n",".agents/skills/review-pr/SKILL.md":"---\nname: review-pr\ndescription: >\n  Review GitHub pull requests for the Gumroad codebase against project guidelines, code quality,\n  and correctness. Use when the user wants to review a PR, check new comments on a PR, provide\n  feedback on code changes, or asks about PR quality. Triggers on: \"review PR\", \"review this PR\",\n  \"check PR #NNN\", any GitHub PR URL (e.g. https://github.com/antiwork/gumroad/pull/NNNN),\n  \"what's new on the PR\", \"check comments\", \"review the diff\", \"give feedback on this PR\",\n  or requests to evaluate code changes in a pull request.\n---\n\n# PR Review\n\nReview pull requests for code quality, correctness, and CONTRIBUTING.md compliance.\n\n**Scope boundary**: This skill evaluates _how the code is written_. For evaluating _whether the PR solves the right problem_, use the `issue-detective` skill instead.\n\n## Workflow\n\n### 1. Fetch PR Context\n\n```bash\ngh pr view <number> --repo antiwork/gumroad\ngh pr diff <number> --repo antiwork/gumroad\ngh pr view <number> --repo antiwork/gumroad --comments\ngh api repos/antiwork/gumroad/pulls/<number>/comments\n```\n\nAlso read `CONTRIBUTING.md` from the repo root — it's the source of truth for all guideline checks.\n\nNote PR status (draft, closed, merged). Skip review if merged or closed unless user insists.\n\n### 2. Understand the Change\n\nFrom the diff and description, determine:\n\n- What changed and why (read the linked issue if referenced)\n- Bug fix, feature, refactor, or chore\n- Which layers are affected (frontend, backend, both, tests only)\n- Size and complexity\n\nRead key modified files in full when the diff alone is insufficient to understand context.\n\n### 3. Review\n\nRun these review passes on the diff:\n\n**Pass 1 — Bugs and Logic Errors**\nWrong conditionals, missing edge cases, race conditions, nil/null handling, off-by-one errors, security vulnerabilities (injection, XSS, CSRF). Focus on code paths introduced or modified by the PR.\n\n**Pass 2 — CONTRIBUTING.md Compliance**\nCheck the diff against every applicable rule in CONTRIBUTING.md (code standards, naming conventions, testing standards, Sidekiq patterns, PR structure, etc.). The file is the single source of truth — do not maintain a separate checklist.\n\n**Pass 3 — Code Clarity**\nEvaluate readability and maintainability of new/modified code. See [references/review-guidance.md](references/review-guidance.md) for what to flag vs what to leave alone. The goal is clear, explicit code — not clever or compact code.\n\n**Pass 4 — PR Structure**\nAI disclosure, description quality (explains _why_), before/after media, test results, appropriate size — all per CONTRIBUTING.md.\n\n**⛔ Media evidence is a BLOCKING check (CONTRIBUTING.md line 17 — a hard `Must`).** If the PR touches product/UI (any view, component, CSS, layout, mobile behavior, user-visible copy) and has **no before/after video or screenshots**, raise it as a **critical** finding at **confidence 95+** and set the Verdict to request-changes. Do NOT wave through a mobile/CSS/layout PR just because the prose description is clear — \"the code looks right\" is not a substitute for visual proof. A missing video on a non-visual change (no walkthrough) is at least an **important** finding. This does not apply to PRs that only touch documentation or agent skill files — the diff itself is the reviewable artifact, so do not raise a missing-media finding for docs- or skill-only PRs.\n\n### 4. Score and Filter\n\nAssign each finding a confidence score (0–100): how likely is this a genuine problem?\n\n**Keep** findings >= 80. **Drop** findings below 80.\n\nSee [references/review-guidance.md](references/review-guidance.md) for noise filtering rules and severity levels.\n\n### 5. Report\n\n```\n## Summary\n\n[1-2 sentences: what the PR does and overall assessment]\n\n## Issues\n\n### [critical|important|suggestion] Title\n**File:** `path/to/file.rb:NN`\n**Confidence:** NN/100\n\n[Concise explanation and fix suggestion if straightforward.]\n\n...\n\n## Checklist\n\n[CONTRIBUTING.md items that are missing — e.g., no AI disclosure, no before/after, missing tests]\n\n## Verdict\n\n[approve / request-changes / comment-only — with brief justification]\n```\n\nWrite the review to `gh-pr-review.md` in the repo root. Do NOT stage or commit. Do NOT post to GitHub.\n\n### 6. Iterate\n\nWhen the user asks to re-check a PR (new comments, updated diff):\n\n```bash\ngh pr view <number> --repo antiwork/gumroad --comments\ngh api repos/antiwork/gumroad/pulls/<number>/comments\ngh pr diff <number> --repo antiwork/gumroad\n```\n\nFocus on what changed since the last review. Don't repeat prior findings unless still unaddressed.\n\n## Important\n\n- Use `gh` read-only only. Never approve, comment on, or request changes via CLI.\n- Review what the PR _introduces_ — not pre-existing code.\n- Be direct and concise. No filler praise.\n- Prioritize substantive issues over cosmetic ones.\n- When unsure, state uncertainty rather than false confidence.\n- For large PRs (1k+ lines), note the PR should be broken up.\n",".agents/skills/create-issue/SKILL.md":"---\nname: create-issue\ndescription: >\n  Draft GitHub issues for the Gumroad codebase. Outputs to an unstaged markdown file in the repo\n  root. Never creates issues on GitHub. Triggers on: \"create issue\", \"write issue\", \"draft issue\",\n  \"write a bug report\", \"file an issue\", \"create a ticket\", or when the user describes a problem\n  or feature they want to turn into a GitHub issue.\n---\n\n# Issue Drafting\n\nDraft well-structured GitHub issues. Output to an unstaged `.md` file — never post to GitHub.\n\n## Workflow\n\n### 1. Understand the Problem\n\nGather context from the user. Ask clarifying questions if needed:\n\n- What's happening vs what should happen?\n- Who's affected and how often?\n- Any workarounds in use?\n- Is there data that quantifies the impact?\n\nIf the user points to code, trace the relevant paths to understand the root cause. Include aggregated, anonymized data that quantifies the impact when available (e.g., \"99 purchases across 82 sellers are in this state\"). Never include PII — no emails, names, IDs, or payment details.\n\n### 2. Decide: Single Issue or Parent + Sub-issues\n\nBefore writing, assess the scope:\n\n**Single issue** when:\n\n- One clear problem with one solution path\n- The fix would be one PR (~100 LOC per CONTRIBUTING.md)\n- No independent workstreams\n\n**Parent + sub-issues** when:\n\n- The solution has multiple independently shippable parts\n- Different parts could be worked on in parallel or by different people\n- The total scope would exceed one reasonably-sized PR\n- There's a logical sequence (e.g., API integration → webhook fix → backfill)\n\nIf suggesting sub-issues, number the solution parts clearly and note which can be done independently vs which depend on each other. Write the parent issue as the full document, then note where to split.\n\n### 3. Write the Issue\n\nFollow the structure in CONTRIBUTING.md.\n\n**Title** — Carries most of the weight. Specific and actionable.\n\n**## What**\nWhat needs to change. Be concrete — current vs desired behavior, who is affected, checkbox task list for multiple deliverables.\n\n**## Why**\nWhy this matters — business rationale or user impact, root cause if it's a bug, links to related issues or prior discussions.\n\n### 4. Output\n\nWrite to `gh-issue-draft.md` in the repo root. Do NOT stage or commit.\n\nIf the issue should be split into sub-issues, write the parent issue first, then add a `## Sub-issues` section at the end listing each sub-issue with a one-line summary. The user can split them manually.\n\n## Style\n\nFollow CONTRIBUTING.md for terminology and tone. Additionally:\n\n- Concise and direct. No jargon, no filler.\n- Ground claims in data or code references.\n- Reference code by area or concept (\"the dispute webhook handler\"), not by file:line.\n- The issue is public (open source). Self-contained for OSS contributors with no access to internal tools.\n\n## Important\n\n- Use `gh` read-only only. Never create issues via CLI.\n- The user reviews and posts manually — just write the best possible draft.\n",".agents/skills/commit/SKILL.md":"---\nname: commit\ndescription: Stage and commit changes with a clear, concise commit message.\nargument-hint: [optional message hint]\nallowed-tools: Bash(git *)\n---\n\n# Commit\n\nCreate a commit for the current changes.\n\n## Steps\n\n1. Run `git status` (without `-uall`) to see untracked and modified files.\n2. Run `git diff` and `git diff --cached` to understand staged and unstaged changes.\n3. Run `git log --oneline -5` to see recent commit style.\n4. **Run `bin/test-confidence` to verify your changes don't break anything.** Opus analyzes your diff and runs the right tests. Wait for 99% confidence (green bar) before proceeding. If any test fails, fix it first.\n5. **Run linters on changed files before staging:**\n   - Ruby files: `bundle exec rubocop --force-exclusion -a <files>`\n   - JS/TS files: `npm run lint-fast -- --max-warnings 0 --fix --no-warn-ignored <files>`\n   - TypeScript type check: `npx tsc --noEmit` (required, catches type errors that eslint skips)\n   - CSS/SCSS/JSON/MD files: `npx prettier --write <files>`\n   - SVG files: `npx svgo --multipass <files>`\n   - Fix any lint errors or type errors before proceeding. Do not commit code that fails linting or type checking.\n6. Stage the relevant files by name — avoid `git add -A` or `git add .`.\n7. Write a commit message and commit.\n\nIf `$ARGUMENTS` is provided, use it as a hint for the message.\n\n## Commit message rules\n\n- Use the imperative mood (\"Add\", \"Fix\", \"Remove\", not \"Added\", \"Fixes\", \"Removed\").\n- Be concise: one short sentence, ideally under 50 characters.\n- Do NOT use conventional commit prefixes (no `feat:`, `fix:`, `chore:`, etc.).\n- Do NOT add any `Co-Authored-By` or similar trailers.\n- Focus on **what** changed and **why**, not how.\n- If a second line is needed for context, keep it brief.\n\n## Examples of good messages\n\n- `Prevent discount code from being cleared on edit`\n- `Add integration test for offer code persistence`\n- `Remove unused legacy export helper`\n- `Fix thumbnail missing in upsell insert`\n\n$ARGUMENTS\n",".agents/skills/test-confidence/SKILL.md":"---\nname: test-confidence\ndescription: AI-driven test execution. Opus decides what to run and how confident to be, based on your diff.\nargument-hint: \"--full to run to 100% | --strict to halt on pre-existing failures\"\nallowed-tools: Bash(git *), Bash(bundle exec rspec *), Bash(cat *), Bash(find *), Bash(wc *), Bash(head *), Bash(tail *), Bash(grep *), Bash(bin/test-confidence *)\n---\n\n# Test Confidence\n\nRun `bin/test-confidence` to have Opus 4.7 analyze your diff, decide the risk level, plan which tests to run and in what order, and set confidence milestones. The AI decides the shape of the curve based on this specific diff.\n\n## Usage\n\n```bash\nbin/test-confidence            # Run to 99%, stop. Skips past pre-existing failures.\nbin/test-confidence --full     # Run to 100%\nbin/test-confidence --strict   # Halt on any failure, including pre-existing\n```\n\n`ANTHROPIC_API_KEY` is auto-sourced from `.env` if not exported.\n\nIf `$ARGUMENTS` is provided, pass it through: `bin/test-confidence $ARGUMENTS`\n\n## How it works\n\n1. Finds changed files (branch diff vs main for PR branches, local diff on main)\n2. Hashes the diff and checks `tmp/test-confidence/` for a cached plan; reuses if found\n3. Otherwise sends the diff + spec tree + touched directories to Opus 4.7 in one call\n4. Opus returns a plan: risk level, ordered test list, confidence milestones\n5. Script executes the plan, showing yellow progress bar toward 99%\n6. At 99%, bar turns green. Safe to commit.\n7. With `--full`, continues running remaining tests toward 100%\n\n## Pre-existing failure detection\n\nWhen a spec fails, the script applies a two-step check:\n\n1. **Path heuristic** — does the failing spec file or its source counterpart (e.g., `app/models/user.rb` for `spec/models/user_spec.rb`) appear in the diff?\n   - **In the diff** → real regression. Halt immediately.\n   - **Not in the diff** → suspect pre-existing. Verify on merge-base.\n2. **Merge-base verify** — re-run the failing rspec examples in a temporary worktree at the branch's merge-base with `main`.\n   - **Still fails on merge-base** → confirmed pre-existing. Continue.\n   - **Passes on merge-base** → cross-file regression caught. Halt.\n\nThe verify step is skipped (heuristic verdict trusted) when the diff includes a `db/migrate/*.rb` (DB schema drift) or `Gemfile.lock` (bundler drift), or when no merge-base is available. `--strict` skips both checks and halts on every failure.\n\nCost profile: zero overhead in the common no-failure case; verify only runs on heuristic-flagged \"pre-existing\" failures. Catches the dangerous direction (silent regression skipped) while keeping false-alarm investigation costs bounded.\n\nThe key insight: Opus decides ad hoc how many tests are needed for each confidence level. A comment-only change might need 2 tests for 99%. A payment model refactor might need 100.\n\n## When to use\n\nRun this before every commit. It replaces manually picking which specs to run.\n\n$ARGUMENTS\n","AGENTS.md":"@CONTRIBUTING.md\n\n## Agent Skills\n\nProject skills live in `.agents/skills/<name>/SKILL.md`. The `.claude/skills/<name>` entries are symlinks for Claude Code compatibility. When adding or updating a project skill, edit `.agents/skills` and keep the matching Claude symlink in place.\n\n## Code comments\n\nWrite comments for a reader who already knows this codebase. They are a colleague, not a\nstudent — so say the one thing the code cannot say, and stop.\n\nKeep: the non-obvious *why*, ordering constraints, and traps that will bite the next person\nto edit this. Cut: vendor or company history, anything restating the line below it, narration\nof how the bug was found, and re-explanations of a named constant the reader can click\nthrough to. If a comment runs past about three lines, ask what you would delete.\n\nA good test: would a maintainer who knows this file find this line worth reading? If not, it\nis noise — and noise is what teaches people to skip the comments that matter.\n",".claude/skills/create-issue":"../../.agents/skills/create-issue",".claude/skills/email-blast":"../../.agents/skills/email-blast",".claude/skills/gumroad-prod-console":"../../.agents/skills/gumroad-prod-console",".github/copilot-instructions.md":"See CONTRIBUTING.md for development guidelines\n",".claude/skills/commit":"../../.agents/skills/commit",".claude/skills/review-pr":"# Claude Directive: review-pr\n\nPath: `.claude/skills/review-pr`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/review-pr)",".claude/skills/test-confidence":"# Claude Directive: test-confidence\n\nPath: `.claude/skills/test-confidence`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/test-confidence)",".agents/skills/gumroad-prod-console/references/common-queries.md":"# References Skill\n\nPath: `.agents/skills/gumroad-prod-console/references/common-queries.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/gumroad-prod-console/references/common-queries.md)",".agents/skills/review-pr/references/review-guidance.md":"# References Skill\n\nPath: `.agents/skills/review-pr/references/review-guidance.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/review-pr/references/review-guidance.md)"},"files":{".agents/skills/gumroad-prod-console/SKILL.md":"---\nname: gumroad-prod-console\ndescription: >\n  Execute read-only Ruby/Rails commands against Gumroad's production database for debugging\n  and investigation. Use when the user needs to debug production issues, look up data,\n  investigate user reports, check records, or query production state. Triggers on: \"check in\n  prod\", \"debug this in prod\", \"look up user/purchase/product in production\", \"production\n  console\", \"investigate in prod\", \"query production\", \"what's happening in prod\", \"look up\n  a user by email\", \"who bought this product\", \"why is this user blocked\", \"find this\n  sale/purchase\", \"how many X does Y have\", or any request to examine live Gumroad data —\n  even when the user doesn't say \"prod\" explicitly.\n---\n\n# Gumroad production console\n\nRead-only Rails runner against the production read replica via bastion SSH.\n\n## Execution\n\n```bash\n.claude/skills/gumroad-prod-console/scripts/prod_query.sh 'puts User.count'\n.claude/skills/gumroad-prod-console/scripts/prod_query.sh /tmp/query.rb\necho 'puts User.count' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\n```\n\nMulti-line queries: write a temp `.rb` file, pass its path. Bash tool timeout is ~120s — wrap slow queries in `WithMaxExecutionTime.timeout_queries(seconds: 30) { ... }`. Queries run against the read replica (`DATABASE_WORKER_REPLICA1_HOST` by default).\n\nFollow-up queries are cheap — start scoped (one record, one field), then drill in as the investigation clarifies. Avoid the urge to return everything in a single large query.\n\nWarm hops: `prod_query.sh` multiplexes SSH to the bastion (`ControlPersist`) and reuses the last-good instance IP for 10 minutes (`PROD_IP_CACHE_TTL`). A second query should skip EC2 discovery. Pin with `PROD_INSTANCE_IP` to skip it entirely. Tracked in gumroad-private#2197.\n\nPersistent runner: the first replica-default query on a host boots a long-lived `rails runner` loop (`scripts/prod_runner_loop.rb`) inside the puma container; later queries are spooled to it and skip Rails boot entirely (~1s warm vs ~14s one-shot). The loop forks per query, keeps stdout/stderr separate, idles out after 30 minutes without work, and is replaced automatically when the local `prod_runner_loop.rb` changes. Non-default `PROD_DB_HOST_VAR` sessions (primary writes) always take the one-shot path; `PROD_NO_RUNNER_LOOP=1` opts out.\n\n## Safety\n\n- **Read-only.** Never write, update, or delete.\n- **No Redis `KEYS` or `FLUSH*`.** `prod_query.sh` prepends `scripts/redis_command_guard.rb`, which refuses them: one `KEYS` over the ~4.6M-key primary blocks it for seconds and 500s the site. Use `$redis.scan_each(match:, count: 1000).first(n)`.\n- Always `.limit()` / `.first()` / `.take()` — never unbounded result sets.\n- Prefer `.pluck(:col, :col)` over loading full AR objects.\n- Mask PII in output (truncate emails, addresses, payment details).\n- `.explain` before querying large tables without indexed conditions.\n- Emit structured output (JSON for complex, `.inspect` for simple) so results are parseable.\n\n## Gumroad-specific gotchas\n\n### External IDs, not primary keys\n\nAdmin URLs and public IDs use **external IDs** (`ExternalId` module — Base64 strings like `aBcDeFgHiJkLmNoPqRsTuQ==`), not integer PKs.\n\n```ruby\nPurchase.find_by_external_id(\"aBcDeFgHiJkLmNoPqRsTuQ==\")  # correct\nPurchase.find(\"aBcDeFgHiJkLmNoPqRsTuQ==\")                  # WRONG — treats it as a PK, silently returns the wrong record\n```\n\n### Model naming\n\n| Model | Note |\n|---|---|\n| `Link` | The product model (legacy name). `find_by(unique_permalink:)` for permalinks; `alive` scope for non-deleted. |\n| `Installment` | Subscriptions/recurring (not ActiveRecord's sense of \"installment\"). `where(link_id:, alive: true)`. |\n| `Comment` | Admin notes on records. `content` field, **not** `body`. |\n| `MerchantAccount` | Processor-specific — users can have multiple. `where(user_id:, charge_processor_id:)`. |\n| `Purchase` | `successful` scope for completed sales. `where(email:)`, `where(link_id:)`. |\n\n`User`, `Balance`, `Dispute`, `Follower`, `CustomDomain` behave as names suggest.\n\n### Sidekiq queues\n\n`critical` (12k limit — payouts, webhooks, receipts) → `default` (300k — general) → `long` (PDF stamping etc.) → `low` (expiry). Queue limits at `app/controllers/healthcheck_controller.rb:28`; worker ordering at `docker/web/sidekiq_worker.sh`.\n\n### DevTools (Gumroad-specific helpers)\n\nSee `lib/utilities/dev_tools.rb`:\n\n- `DevTools.reindex_all_for_user(user_id)` — reindex ES data\n- `DevTools.reimport_follower_events_for_user!(user)` — reimport follower analytics\n\nFor Gumroad-specific scopes and associations (`alive`, `successful`, `unpaid_balance_cents`, `payments`, `products`, Flipper checks, Sidekiq introspection), see [references/common-queries.md](references/common-queries.md).\n\n### Stale bastion host keys look like unhealthy instances\n\nEC2 recycles private IPs, so the **bastion's** `known_hosts` accumulates stale keys and refuses the onward hop with `REMOTE HOST IDENTIFICATION HAS CHANGED` / `Offending ECDSA key`. From outside this is easy to mistake for a hung instance, and it silently shrinks the usable pool each time instances are replaced.\n\n`prod_query.sh` catches them without touching the pin: a candidate is recorded as stale-keyed when a changed-key complaint names its address — including on a probe that looked healthy, since the forced command exits 0 after its own hop refused — and the run reports those addresses with the one-hop `ssh-keygen -R` command to run by hand.\n\n- **The pin is never removed automatically.** Every textbook trigger for a removal is something a pin exists to defend against: EC2 membership says the address is a pool member, not that the key it serves is the instance's, and a changed-key complaint is an alarm rather than proof of a recycle — deleting the entry and reconnecting pins whatever answered. Clearing is an explicit operator action, printed as a one-hop command.\n- **Only genuinely outdated keys are reported.** A plain timeout, a container that is still starting, or a network blip leaves the recorded key alone — only SSH naming that address counts. The onward hop usually just *warns* about a changed key and connects anyway (recycled IPs make that the steady state), so a warn-and-proceed failure stays on the patient-retry list too. Only an outright `Host key verification failed` refusal drops a candidate from the retry.\n- **A pool rejected entirely for stale keys says so**, with that same command, instead of the generic health-probe error.\n\n- **`LC_PAPER=127.0.0.1` means the bastion itself.** The forced command jumps to whatever `LC_PAPER` names, and omitting the variable — or the `-o SendEnv=LC_PAPER` that ships it — fails with `ssh: Could not resolve hostname`; instance IPs run the command on that instance. Anything aimed at the bastion's own files goes through `127.0.0.1`.\n- **The warning text is often noise.** A run can print the whole man-in-the-middle banner for a *previous* hop and still succeed. Judge by exit code and `MARK` output, not the banner. `rc 255` is the transport dropping mid-flight, so the outcome is unknown — do not read it as a failed query. Any *other* nonzero rc whose only output is the banner means the query failed: look for `Query process killed by SIGKILL` or the query's own output.\n\n### \"No instance passed the health probe\" usually means slow, not down\n\nThe candidate probe is deliberately impatient (20s) so one hung host cannot eat the caller's budget, but that also rejects hosts which are merely slow under load. When *every* candidate fails, a fleet-wide outage is the less likely reading.\n\n`prod_query.sh` now retries the non-stale-key rejections with a patient probe before giving up, and says so (`answered on the patient retry (slow, not unhealthy)`). Only the all-rejected path pays for this.\n\nBoth passes draw down one shared **90s selection budget** (`PROD_SELECT_BUDGET`), so picking a host can never consume the caller's whole ~120s Bash-tool window before the query starts — a large pool shortens each probe rather than adding to the total. A third of the budget is held back for the patient pass, so a pool of slow hosts cannot drain everything in the fast pass and starve the retry. Running out gives a distinct message that says the pool may just be slow, instead of the misleading \"no instance passed the health probe\". Raise the budget when you have more wall clock than the Bash tool allows:\n\n```bash\nPROD_SELECT_BUDGET=240 timeout 560 bash .agents/skills/gumroad-prod-console/scripts/prod_query.sh q.rb\n```\n\nIf you still get the hard error, force a host rather than assuming production is down:\n\n```bash\nPROD_INSTANCE_IP=10.1.34.180 bash .agents/skills/gumroad-prod-console/scripts/prod_query.sh q.rb\n```\n\nThis matters for **watchers**, not just interactive use: a cron that treats an unreadable console as \"nothing to report\" goes silent indefinitely and looks healthy. On 2026-07-29 all 8 candidates were rejected while a forced host answered the same query in 13s.\n\n### Grepping only for `^MARK` can hide a total failure\n\n`... | grep \"^MARK\"` on a run that never reached Rails returns empty with **exit 0**, which reads as \"query worked, no rows\". Capture to files and echo the exit code:\n\n```bash\ntimeout 560 prod_query.sh /tmp/q.rb > /tmp/q.out 2>/tmp/q.err; echo \"exit=$?\"\ngrep -a \"^MARK\" /tmp/q.out\n```\n\nExit `124` = outer timeout (query too heavy). `255` = SSH itself failed: either the hop dropped before Rails started, or the transport dropped mid-flight with the spool state unknown — treat the query's outcome as unknown rather than failed.\n\n### `association.unscoped` reads the whole table\n\n`Link` has no `default_scope`, so `user.links` already includes deleted rows — there is nothing to reach for `.unscoped` for. On an association it drops the `user_id` condition instead, leaving `SELECT * FROM links` (millions of rows). Iterating that gets the query process OOM-killed: the run exits nonzero, prints `Query process killed by SIGKILL`, and the stale host-key banner above it is **not** the cause. Use `user.links` (or `Link.where(user_id: user.id)`).\n\n### Keep Stripe API calls out of loops\n\nThe DB is fast; `Stripe::Transfer.list` / `Charge.retrieve` per record is what blows the timeout — even ~5 accounts with one Stripe call each can hit exit `124`. Use raw SQL via `ActiveRecord::Base.connection.select_all` for population questions, and fetch at most 1–2 Stripe objects per run when you need live processor state.\n\n## Requirements\n\n- An AWS profile with `ec2:DescribeInstances` on the prod account. The script defaults to the profile `gumroad-prod` (created by `scripts/setup.sh`). Override by exporting `AWS_PROFILE` or setting `PROD_AWS_PROFILE` in your config file.\n- SSH access to your production bastion (defaults to `bastion-production.gumroad.net`)\n\n### Gumroad team one-time setup\n\nIf you previously ran this script via `GUMROAD_DEPLOYMENT_DIR` and `.env.aws`, run the helper from the repo root to migrate those creds into an AWS CLI profile:\n\n```bash\n.claude/skills/gumroad-prod-console/scripts/setup.sh\n# or pass an explicit path:\n.claude/skills/gumroad-prod-console/scripts/setup.sh ~/path/to/.env.aws\n```\n\nThe helper will also offer to append `export AWS_PROFILE=gumroad-prod` to your shell profile — say yes and reload the shell. After this, `gumroad-private` is no longer required to run the skill.\n\n## Configuration (self-hosters)\n\nDefaults target Gumroad's prod infra. If you're running your own Gumroad fork, override by creating `~/.config/gumroad-prod-console.env`:\n\n```bash\nPROD_BASTION=bastion.mycompany.com\nPROD_SECURITY_GROUP=my-web-sg\nPROD_CONTAINER_FILTER=app-*\nPROD_DB_HOST_VAR=MY_READ_REPLICA_HOST\nPROD_AWS_PROFILE=my-aws-profile\n```\n\nOr export the same variables before invoking the script.\n",".agents/skills/email-blast/SKILL.md":"---\nname: email-blast\ndescription: >\n  Send one-off email blasts to Gumroad creators directly via production console, no PR or\n  deploy needed. Handles drafting, recipient query, count validation via Metabase, dry-run\n  preview, and execution. Use when asked to \"email creators\", \"send an announcement\",\n  \"blast sellers\", \"notify users\", or any request to send emails to Gumroad creators at scale.\n---\n\n# Email Blast\n\nSend one-off emails to Gumroad creators by executing Ruby inline against the production console. No PR, no deploy, no service class needed.\n\n## Prerequisites\n\n- Production console access configured via the `gumroad-prod-console` skill (see its SKILL.md for setup)\n- Metabase API key (in 1Password: \"Metabase (Gumroad)\" → \"API Key\")\n\nAll production Ruby execution in this skill goes through `.claude/skills/gumroad-prod-console/scripts/prod_query.sh`. That script targets the read replica for MySQL, which is sufficient here — `User.alive.not_suspended...find_each` is a read, and `deliver_later` + `$redis.set` hit Redis, not MySQL. Do not add any MySQL-write operations to blast scripts.\n\n## Workflow\n\n### 1. Clarify the request\n\nFrom the human's request, extract:\n- **Who** — recipient criteria (e.g. \"all active creators\", \"creators who earned > $1000\")\n- **What** — the message to send (subject + body)\n- **Reply-to** — default `support@gumroad.com`\n\n### 2. Draft the email\n\nWrite a `subject` and HTML `body`:\n- Simple HTML only: `<p>` tags, `<a>` links\n- Sign off: `<p>— The Gumroad Team</p>`\n- No `<ul>`/`<li>` (renders poorly). Use `<p>• Item</p>` for bullets.\n- Keep it short and direct\n- **Always use** `from: \"Gumroad <gumroad@creators.gumroad.com>\"` and `sender_domain: :creators`\n- The OneOffMailer template renders the subject as an `<h2>` heading at the top automatically\n\n**Present the draft for human approval before proceeding.**\n\n### 3. Count recipients via Metabase\n\nBuild a SQL query and validate the count:\n```bash\nMETABASE_KEY=$(op item get \"Metabase (Gumroad)\" --vault Gumclaw --fields \"API Key\" --reveal)\ncurl -s -X POST \"https://gumroad.metabaseapp.com/api/dataset\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-Metabase-Session: $METABASE_KEY\" \\\n  -d '{\n    \"database\": 2,\n    \"type\": \"native\",\n    \"native\": { \"query\": \"SELECT COUNT(*) FROM users WHERE deleted_at IS NULL AND is_suspended = 0 AND ...\" }\n  }' | python3 -c \"import json,sys; print(json.load(sys.stdin)['data']['rows'][0][0])\"\n```\n\n**Always filter**: `deleted_at IS NULL AND is_suspended = 0` (alive, not suspended).\n\nShow the count to the human before executing.\n\n### 4. Dry run (preview)\n\nGenerate a script that sends to just 1-2 test recipients first. Run via `prod_query.sh`:\n```bash\ncat << 'RUBY' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\ntest_user = User.find_by(email: \"sahil@gumroad.com\")\nOneOffMailer.email(\n  email: test_user.form_email,\n  from: \"Gumroad <gumroad@creators.gumroad.com>\",\n  subject: \"SUBJECT HERE\",\n  body: <<~HTML,\n    <p>Body here.</p>\n    <p>— The Gumroad Team</p>\n  HTML\n  reply_to: \"support@gumroad.com\",\n  sender_domain: :creators\n).deliver_later(queue: \"low\")\nputs \"Test email enqueued to #{test_user.form_email}\"\nRUBY\n```\n\n**Wait for human confirmation that the test email looks good before proceeding.**\n\n### 5. Execute the blast\n\nGenerate the full script with Redis checkpointing for resumability:\n\n```bash\ncat << 'RUBY' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\nredis_key = \"email_blast_DESCRIPTIVE_KEY_last_user_id\"\nlast_processed_id = $redis.get(redis_key).to_i\ncount = 0\n\nUser.alive.not_suspended.where(\"id > ?\", last_processed_id).where(CRITERIA).find_each(batch_size: 500) do |user|\n  next if user.form_email.blank?\n\n  OneOffMailer.email(\n    email: user.form_email,\n    from: \"Gumroad <gumroad@creators.gumroad.com>\",\n    subject: \"SUBJECT\",\n    body: <<~HTML,\n      <p>Body here.</p>\n      <p>— The Gumroad Team</p>\n    HTML\n    reply_to: \"support@gumroad.com\",\n    sender_domain: :creators\n  ).deliver_later(queue: \"low\")\n\n  $redis.set(redis_key, user.id)\n  count += 1\n  Rails.logger.info \"Enqueued email for user #{user.id} (#{count} total)\" if count % 100 == 0\nend\n\nputs \"Done. Enqueued #{count} emails.\"\nRUBY\n```\n\n### 6. Resume / Reset\n\n- **Resume** (if interrupted): Just re-run the same script. Redis checkpoint skips already-processed users.\n- **Reset** (to re-send): Delete the Redis key first:\n  ```bash\n  echo '$redis.del(\"email_blast_DESCRIPTIVE_KEY_last_user_id\")' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\n  ```\n\n## Safety Rules\n\n1. **Always get human approval** on subject, body, and recipient count before executing\n2. **Always send a dry-run** to a test recipient first (sahil@gumroad.com or the requester)\n3. **Always use Redis checkpointing** — blasts can be interrupted and resumed\n4. **Always use `deliver_later(queue: \"low\")`** — never `deliver_now` (would block the runner)\n5. **Always filter** `alive.not_suspended` and `form_email.present?`\n6. **Never send from** `hi@customers.gumroad.com` — that's for transactional emails only\n7. **Log progress** so you can verify emails are being enqueued\n\n## Key References\n\n- `OneOffMailer`: `app/mailers/one_off_mailer.rb`\n- Example service: `app/services/onetime/notify_sellers_about_legacy_fee_migration.rb`\n- `form_email`: The user's preferred contact email (may differ from login email)\n- Sender domains: `:creators` → `creators.gumroad.com`, default → `customers.gumroad.com`\n",".agents/skills/review-pr/SKILL.md":"---\nname: review-pr\ndescription: >\n  Review GitHub pull requests for the Gumroad codebase against project guidelines, code quality,\n  and correctness. Use when the user wants to review a PR, check new comments on a PR, provide\n  feedback on code changes, or asks about PR quality. Triggers on: \"review PR\", \"review this PR\",\n  \"check PR #NNN\", any GitHub PR URL (e.g. https://github.com/antiwork/gumroad/pull/NNNN),\n  \"what's new on the PR\", \"check comments\", \"review the diff\", \"give feedback on this PR\",\n  or requests to evaluate code changes in a pull request.\n---\n\n# PR Review\n\nReview pull requests for code quality, correctness, and CONTRIBUTING.md compliance.\n\n**Scope boundary**: This skill evaluates _how the code is written_. For evaluating _whether the PR solves the right problem_, use the `issue-detective` skill instead.\n\n## Workflow\n\n### 1. Fetch PR Context\n\n```bash\ngh pr view <number> --repo antiwork/gumroad\ngh pr diff <number> --repo antiwork/gumroad\ngh pr view <number> --repo antiwork/gumroad --comments\ngh api repos/antiwork/gumroad/pulls/<number>/comments\n```\n\nAlso read `CONTRIBUTING.md` from the repo root — it's the source of truth for all guideline checks.\n\nNote PR status (draft, closed, merged). Skip review if merged or closed unless user insists.\n\n### 2. Understand the Change\n\nFrom the diff and description, determine:\n\n- What changed and why (read the linked issue if referenced)\n- Bug fix, feature, refactor, or chore\n- Which layers are affected (frontend, backend, both, tests only)\n- Size and complexity\n\nRead key modified files in full when the diff alone is insufficient to understand context.\n\n### 3. Review\n\nRun these review passes on the diff:\n\n**Pass 1 — Bugs and Logic Errors**\nWrong conditionals, missing edge cases, race conditions, nil/null handling, off-by-one errors, security vulnerabilities (injection, XSS, CSRF). Focus on code paths introduced or modified by the PR.\n\n**Pass 2 — CONTRIBUTING.md Compliance**\nCheck the diff against every applicable rule in CONTRIBUTING.md (code standards, naming conventions, testing standards, Sidekiq patterns, PR structure, etc.). The file is the single source of truth — do not maintain a separate checklist.\n\n**Pass 3 — Code Clarity**\nEvaluate readability and maintainability of new/modified code. See [references/review-guidance.md](references/review-guidance.md) for what to flag vs what to leave alone. The goal is clear, explicit code — not clever or compact code.\n\n**Pass 4 — PR Structure**\nAI disclosure, description quality (explains _why_), before/after media, test results, appropriate size — all per CONTRIBUTING.md.\n\n**⛔ Media evidence is a BLOCKING check (CONTRIBUTING.md line 17 — a hard `Must`).** If the PR touches product/UI (any view, component, CSS, layout, mobile behavior, user-visible copy) and has **no before/after video or screenshots**, raise it as a **critical** finding at **confidence 95+** and set the Verdict to request-changes. Do NOT wave through a mobile/CSS/layout PR just because the prose description is clear — \"the code looks right\" is not a substitute for visual proof. A missing video on a non-visual change (no walkthrough) is at least an **important** finding. This does not apply to PRs that only touch documentation or agent skill files — the diff itself is the reviewable artifact, so do not raise a missing-media finding for docs- or skill-only PRs.\n\n### 4. Score and Filter\n\nAssign each finding a confidence score (0–100): how likely is this a genuine problem?\n\n**Keep** findings >= 80. **Drop** findings below 80.\n\nSee [references/review-guidance.md](references/review-guidance.md) for noise filtering rules and severity levels.\n\n### 5. Report\n\n```\n## Summary\n\n[1-2 sentences: what the PR does and overall assessment]\n\n## Issues\n\n### [critical|important|suggestion] Title\n**File:** `path/to/file.rb:NN`\n**Confidence:** NN/100\n\n[Concise explanation and fix suggestion if straightforward.]\n\n...\n\n## Checklist\n\n[CONTRIBUTING.md items that are missing — e.g., no AI disclosure, no before/after, missing tests]\n\n## Verdict\n\n[approve / request-changes / comment-only — with brief justification]\n```\n\nWrite the review to `gh-pr-review.md` in the repo root. Do NOT stage or commit. Do NOT post to GitHub.\n\n### 6. Iterate\n\nWhen the user asks to re-check a PR (new comments, updated diff):\n\n```bash\ngh pr view <number> --repo antiwork/gumroad --comments\ngh api repos/antiwork/gumroad/pulls/<number>/comments\ngh pr diff <number> --repo antiwork/gumroad\n```\n\nFocus on what changed since the last review. Don't repeat prior findings unless still unaddressed.\n\n## Important\n\n- Use `gh` read-only only. Never approve, comment on, or request changes via CLI.\n- Review what the PR _introduces_ — not pre-existing code.\n- Be direct and concise. No filler praise.\n- Prioritize substantive issues over cosmetic ones.\n- When unsure, state uncertainty rather than false confidence.\n- For large PRs (1k+ lines), note the PR should be broken up.\n",".agents/skills/create-issue/SKILL.md":"---\nname: create-issue\ndescription: >\n  Draft GitHub issues for the Gumroad codebase. Outputs to an unstaged markdown file in the repo\n  root. Never creates issues on GitHub. Triggers on: \"create issue\", \"write issue\", \"draft issue\",\n  \"write a bug report\", \"file an issue\", \"create a ticket\", or when the user describes a problem\n  or feature they want to turn into a GitHub issue.\n---\n\n# Issue Drafting\n\nDraft well-structured GitHub issues. Output to an unstaged `.md` file — never post to GitHub.\n\n## Workflow\n\n### 1. Understand the Problem\n\nGather context from the user. Ask clarifying questions if needed:\n\n- What's happening vs what should happen?\n- Who's affected and how often?\n- Any workarounds in use?\n- Is there data that quantifies the impact?\n\nIf the user points to code, trace the relevant paths to understand the root cause. Include aggregated, anonymized data that quantifies the impact when available (e.g., \"99 purchases across 82 sellers are in this state\"). Never include PII — no emails, names, IDs, or payment details.\n\n### 2. Decide: Single Issue or Parent + Sub-issues\n\nBefore writing, assess the scope:\n\n**Single issue** when:\n\n- One clear problem with one solution path\n- The fix would be one PR (~100 LOC per CONTRIBUTING.md)\n- No independent workstreams\n\n**Parent + sub-issues** when:\n\n- The solution has multiple independently shippable parts\n- Different parts could be worked on in parallel or by different people\n- The total scope would exceed one reasonably-sized PR\n- There's a logical sequence (e.g., API integration → webhook fix → backfill)\n\nIf suggesting sub-issues, number the solution parts clearly and note which can be done independently vs which depend on each other. Write the parent issue as the full document, then note where to split.\n\n### 3. Write the Issue\n\nFollow the structure in CONTRIBUTING.md.\n\n**Title** — Carries most of the weight. Specific and actionable.\n\n**## What**\nWhat needs to change. Be concrete — current vs desired behavior, who is affected, checkbox task list for multiple deliverables.\n\n**## Why**\nWhy this matters — business rationale or user impact, root cause if it's a bug, links to related issues or prior discussions.\n\n### 4. Output\n\nWrite to `gh-issue-draft.md` in the repo root. Do NOT stage or commit.\n\nIf the issue should be split into sub-issues, write the parent issue first, then add a `## Sub-issues` section at the end listing each sub-issue with a one-line summary. The user can split them manually.\n\n## Style\n\nFollow CONTRIBUTING.md for terminology and tone. Additionally:\n\n- Concise and direct. No jargon, no filler.\n- Ground claims in data or code references.\n- Reference code by area or concept (\"the dispute webhook handler\"), not by file:line.\n- The issue is public (open source). Self-contained for OSS contributors with no access to internal tools.\n\n## Important\n\n- Use `gh` read-only only. Never create issues via CLI.\n- The user reviews and posts manually — just write the best possible draft.\n",".agents/skills/commit/SKILL.md":"---\nname: commit\ndescription: Stage and commit changes with a clear, concise commit message.\nargument-hint: [optional message hint]\nallowed-tools: Bash(git *)\n---\n\n# Commit\n\nCreate a commit for the current changes.\n\n## Steps\n\n1. Run `git status` (without `-uall`) to see untracked and modified files.\n2. Run `git diff` and `git diff --cached` to understand staged and unstaged changes.\n3. Run `git log --oneline -5` to see recent commit style.\n4. **Run `bin/test-confidence` to verify your changes don't break anything.** Opus analyzes your diff and runs the right tests. Wait for 99% confidence (green bar) before proceeding. If any test fails, fix it first.\n5. **Run linters on changed files before staging:**\n   - Ruby files: `bundle exec rubocop --force-exclusion -a <files>`\n   - JS/TS files: `npm run lint-fast -- --max-warnings 0 --fix --no-warn-ignored <files>`\n   - TypeScript type check: `npx tsc --noEmit` (required, catches type errors that eslint skips)\n   - CSS/SCSS/JSON/MD files: `npx prettier --write <files>`\n   - SVG files: `npx svgo --multipass <files>`\n   - Fix any lint errors or type errors before proceeding. Do not commit code that fails linting or type checking.\n6. Stage the relevant files by name — avoid `git add -A` or `git add .`.\n7. Write a commit message and commit.\n\nIf `$ARGUMENTS` is provided, use it as a hint for the message.\n\n## Commit message rules\n\n- Use the imperative mood (\"Add\", \"Fix\", \"Remove\", not \"Added\", \"Fixes\", \"Removed\").\n- Be concise: one short sentence, ideally under 50 characters.\n- Do NOT use conventional commit prefixes (no `feat:`, `fix:`, `chore:`, etc.).\n- Do NOT add any `Co-Authored-By` or similar trailers.\n- Focus on **what** changed and **why**, not how.\n- If a second line is needed for context, keep it brief.\n\n## Examples of good messages\n\n- `Prevent discount code from being cleared on edit`\n- `Add integration test for offer code persistence`\n- `Remove unused legacy export helper`\n- `Fix thumbnail missing in upsell insert`\n\n$ARGUMENTS\n",".agents/skills/test-confidence/SKILL.md":"---\nname: test-confidence\ndescription: AI-driven test execution. Opus decides what to run and how confident to be, based on your diff.\nargument-hint: \"--full to run to 100% | --strict to halt on pre-existing failures\"\nallowed-tools: Bash(git *), Bash(bundle exec rspec *), Bash(cat *), Bash(find *), Bash(wc *), Bash(head *), Bash(tail *), Bash(grep *), Bash(bin/test-confidence *)\n---\n\n# Test Confidence\n\nRun `bin/test-confidence` to have Opus 4.7 analyze your diff, decide the risk level, plan which tests to run and in what order, and set confidence milestones. The AI decides the shape of the curve based on this specific diff.\n\n## Usage\n\n```bash\nbin/test-confidence            # Run to 99%, stop. Skips past pre-existing failures.\nbin/test-confidence --full     # Run to 100%\nbin/test-confidence --strict   # Halt on any failure, including pre-existing\n```\n\n`ANTHROPIC_API_KEY` is auto-sourced from `.env` if not exported.\n\nIf `$ARGUMENTS` is provided, pass it through: `bin/test-confidence $ARGUMENTS`\n\n## How it works\n\n1. Finds changed files (branch diff vs main for PR branches, local diff on main)\n2. Hashes the diff and checks `tmp/test-confidence/` for a cached plan; reuses if found\n3. Otherwise sends the diff + spec tree + touched directories to Opus 4.7 in one call\n4. Opus returns a plan: risk level, ordered test list, confidence milestones\n5. Script executes the plan, showing yellow progress bar toward 99%\n6. At 99%, bar turns green. Safe to commit.\n7. With `--full`, continues running remaining tests toward 100%\n\n## Pre-existing failure detection\n\nWhen a spec fails, the script applies a two-step check:\n\n1. **Path heuristic** — does the failing spec file or its source counterpart (e.g., `app/models/user.rb` for `spec/models/user_spec.rb`) appear in the diff?\n   - **In the diff** → real regression. Halt immediately.\n   - **Not in the diff** → suspect pre-existing. Verify on merge-base.\n2. **Merge-base verify** — re-run the failing rspec examples in a temporary worktree at the branch's merge-base with `main`.\n   - **Still fails on merge-base** → confirmed pre-existing. Continue.\n   - **Passes on merge-base** → cross-file regression caught. Halt.\n\nThe verify step is skipped (heuristic verdict trusted) when the diff includes a `db/migrate/*.rb` (DB schema drift) or `Gemfile.lock` (bundler drift), or when no merge-base is available. `--strict` skips both checks and halts on every failure.\n\nCost profile: zero overhead in the common no-failure case; verify only runs on heuristic-flagged \"pre-existing\" failures. Catches the dangerous direction (silent regression skipped) while keeping false-alarm investigation costs bounded.\n\nThe key insight: Opus decides ad hoc how many tests are needed for each confidence level. A comment-only change might need 2 tests for 99%. A payment model refactor might need 100.\n\n## When to use\n\nRun this before every commit. It replaces manually picking which specs to run.\n\n$ARGUMENTS\n","AGENTS.md":"@CONTRIBUTING.md\n\n## Agent Skills\n\nProject skills live in `.agents/skills/<name>/SKILL.md`. The `.claude/skills/<name>` entries are symlinks for Claude Code compatibility. When adding or updating a project skill, edit `.agents/skills` and keep the matching Claude symlink in place.\n\n## Code comments\n\nWrite comments for a reader who already knows this codebase. They are a colleague, not a\nstudent — so say the one thing the code cannot say, and stop.\n\nKeep: the non-obvious *why*, ordering constraints, and traps that will bite the next person\nto edit this. Cut: vendor or company history, anything restating the line below it, narration\nof how the bug was found, and re-explanations of a named constant the reader can click\nthrough to. If a comment runs past about three lines, ask what you would delete.\n\nA good test: would a maintainer who knows this file find this line worth reading? If not, it\nis noise — and noise is what teaches people to skip the comments that matter.\n",".claude/skills/create-issue":"../../.agents/skills/create-issue",".claude/skills/email-blast":"../../.agents/skills/email-blast",".claude/skills/gumroad-prod-console":"../../.agents/skills/gumroad-prod-console",".github/copilot-instructions.md":"See CONTRIBUTING.md for development guidelines\n",".claude/skills/commit":"../../.agents/skills/commit",".claude/skills/review-pr":"# Claude Directive: review-pr\n\nPath: `.claude/skills/review-pr`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/review-pr)",".claude/skills/test-confidence":"# Claude Directive: test-confidence\n\nPath: `.claude/skills/test-confidence`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/test-confidence)",".agents/skills/gumroad-prod-console/references/common-queries.md":"# References Skill\n\nPath: `.agents/skills/gumroad-prod-console/references/common-queries.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/gumroad-prod-console/references/common-queries.md)",".agents/skills/review-pr/references/review-guidance.md":"# References Skill\n\nPath: `.agents/skills/review-pr/references/review-guidance.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/review-pr/references/review-guidance.md)"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"@CONTRIBUTING.md\n\n## Agent Skills\n\nProject skills live in `.agents/skills/<name>/SKILL.md`. The `.claude/skills/<name>` entries are symlinks for Claude Code compatibility. When adding or updating a project skill, edit `.agents/skills` and keep the matching Claude symlink in place.\n\n## Code comments\n\nWrite comments for a reader who already knows this codebase. They are a colleague, not a\nstudent — so say the one thing the code cannot say, and stop.\n\nKeep: the non-obvious *why*, ordering constraints, and traps that will bite the next person\nto edit this. Cut: vendor or company history, anything restating the line below it, narration\nof how the bug was found, and re-explanations of a named constant the reader can click\nthrough to. If a comment runs past about three lines, ask what you would delete.\n\nA good test: would a maintainer who knows this file find this line worth reading? If not, it\nis noise — and noise is what teaches people to skip the comments that matter.\n","isInternal":false,"tokens":227,"sizeBytes":983},{"name":"SKILL.md","path":".agents/skills/commit/SKILL.md","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/commit/SKILL.md","title":"commit","category":"anthropic-skill","format":"markdown","content":"---\nname: commit\ndescription: Stage and commit changes with a clear, concise commit message.\nargument-hint: [optional message hint]\nallowed-tools: Bash(git *)\n---\n\n# Commit\n\nCreate a commit for the current changes.\n\n## Steps\n\n1. Run `git status` (without `-uall`) to see untracked and modified files.\n2. Run `git diff` and `git diff --cached` to understand staged and unstaged changes.\n3. Run `git log --oneline -5` to see recent commit style.\n4. **Run `bin/test-confidence` to verify your changes don't break anything.** Opus analyzes your diff and runs the right tests. Wait for 99% confidence (green bar) before proceeding. If any test fails, fix it first.\n5. **Run linters on changed files before staging:**\n   - Ruby files: `bundle exec rubocop --force-exclusion -a <files>`\n   - JS/TS files: `npm run lint-fast -- --max-warnings 0 --fix --no-warn-ignored <files>`\n   - TypeScript type check: `npx tsc --noEmit` (required, catches type errors that eslint skips)\n   - CSS/SCSS/JSON/MD files: `npx prettier --write <files>`\n   - SVG files: `npx svgo --multipass <files>`\n   - Fix any lint errors or type errors before proceeding. Do not commit code that fails linting or type checking.\n6. Stage the relevant files by name — avoid `git add -A` or `git add .`.\n7. Write a commit message and commit.\n\nIf `$ARGUMENTS` is provided, use it as a hint for the message.\n\n## Commit message rules\n\n- Use the imperative mood (\"Add\", \"Fix\", \"Remove\", not \"Added\", \"Fixes\", \"Removed\").\n- Be concise: one short sentence, ideally under 50 characters.\n- Do NOT use conventional commit prefixes (no `feat:`, `fix:`, `chore:`, etc.).\n- Do NOT add any `Co-Authored-By` or similar trailers.\n- Focus on **what** changed and **why**, not how.\n- If a second line is needed for context, keep it brief.\n\n## Examples of good messages\n\n- `Prevent discount code from being cleared on edit`\n- `Add integration test for offer code persistence`\n- `Remove unused legacy export helper`\n- `Fix thumbnail missing in upsell insert`\n\n$ARGUMENTS\n","frontmatter":{"name":"commit","description":"Stage and commit changes with a clear, concise commit message.","argument-hint":["optional message hint"],"allowed-tools":"Bash(git *)","allowedTools":["Bash(git","*)"]},"allowedTools":["Bash(git","*)"],"isInternal":false,"tokens":527,"sizeBytes":2012},{"name":"SKILL.md","path":".agents/skills/create-issue/SKILL.md","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/create-issue/SKILL.md","title":"create-issue","category":"anthropic-skill","format":"markdown","content":"---\nname: create-issue\ndescription: >\n  Draft GitHub issues for the Gumroad codebase. Outputs to an unstaged markdown file in the repo\n  root. Never creates issues on GitHub. Triggers on: \"create issue\", \"write issue\", \"draft issue\",\n  \"write a bug report\", \"file an issue\", \"create a ticket\", or when the user describes a problem\n  or feature they want to turn into a GitHub issue.\n---\n\n# Issue Drafting\n\nDraft well-structured GitHub issues. Output to an unstaged `.md` file — never post to GitHub.\n\n## Workflow\n\n### 1. Understand the Problem\n\nGather context from the user. Ask clarifying questions if needed:\n\n- What's happening vs what should happen?\n- Who's affected and how often?\n- Any workarounds in use?\n- Is there data that quantifies the impact?\n\nIf the user points to code, trace the relevant paths to understand the root cause. Include aggregated, anonymized data that quantifies the impact when available (e.g., \"99 purchases across 82 sellers are in this state\"). Never include PII — no emails, names, IDs, or payment details.\n\n### 2. Decide: Single Issue or Parent + Sub-issues\n\nBefore writing, assess the scope:\n\n**Single issue** when:\n\n- One clear problem with one solution path\n- The fix would be one PR (~100 LOC per CONTRIBUTING.md)\n- No independent workstreams\n\n**Parent + sub-issues** when:\n\n- The solution has multiple independently shippable parts\n- Different parts could be worked on in parallel or by different people\n- The total scope would exceed one reasonably-sized PR\n- There's a logical sequence (e.g., API integration → webhook fix → backfill)\n\nIf suggesting sub-issues, number the solution parts clearly and note which can be done independently vs which depend on each other. Write the parent issue as the full document, then note where to split.\n\n### 3. Write the Issue\n\nFollow the structure in CONTRIBUTING.md.\n\n**Title** — Carries most of the weight. Specific and actionable.\n\n**## What**\nWhat needs to change. Be concrete — current vs desired behavior, who is affected, checkbox task list for multiple deliverables.\n\n**## Why**\nWhy this matters — business rationale or user impact, root cause if it's a bug, links to related issues or prior discussions.\n\n### 4. Output\n\nWrite to `gh-issue-draft.md` in the repo root. Do NOT stage or commit.\n\nIf the issue should be split into sub-issues, write the parent issue first, then add a `## Sub-issues` section at the end listing each sub-issue with a one-line summary. The user can split them manually.\n\n## Style\n\nFollow CONTRIBUTING.md for terminology and tone. Additionally:\n\n- Concise and direct. No jargon, no filler.\n- Ground claims in data or code references.\n- Reference code by area or concept (\"the dispute webhook handler\"), not by file:line.\n- The issue is public (open source). Self-contained for OSS contributors with no access to internal tools.\n\n## Important\n\n- Use `gh` read-only only. Never create issues via CLI.\n- The user reviews and posts manually — just write the best possible draft.\n","frontmatter":{"name":"create-issue","description":"Draft GitHub issues for the Gumroad codebase. Outputs to an unstaged markdown file in the repo root. Never creates issues on GitHub. Triggers on: \"create issue\", \"write issue\", \"draft issue\", \"write a bug report\", \"file an issue\", \"create a ticket\", or when the user describes a problem or feature they want to turn into a GitHub issue.\n"},"isInternal":false,"tokens":677,"sizeBytes":3004},{"name":"SKILL.md","path":".agents/skills/email-blast/SKILL.md","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/email-blast/SKILL.md","title":"email-blast","category":"anthropic-skill","format":"markdown","content":"---\nname: email-blast\ndescription: >\n  Send one-off email blasts to Gumroad creators directly via production console, no PR or\n  deploy needed. Handles drafting, recipient query, count validation via Metabase, dry-run\n  preview, and execution. Use when asked to \"email creators\", \"send an announcement\",\n  \"blast sellers\", \"notify users\", or any request to send emails to Gumroad creators at scale.\n---\n\n# Email Blast\n\nSend one-off emails to Gumroad creators by executing Ruby inline against the production console. No PR, no deploy, no service class needed.\n\n## Prerequisites\n\n- Production console access configured via the `gumroad-prod-console` skill (see its SKILL.md for setup)\n- Metabase API key (in 1Password: \"Metabase (Gumroad)\" → \"API Key\")\n\nAll production Ruby execution in this skill goes through `.claude/skills/gumroad-prod-console/scripts/prod_query.sh`. That script targets the read replica for MySQL, which is sufficient here — `User.alive.not_suspended...find_each` is a read, and `deliver_later` + `$redis.set` hit Redis, not MySQL. Do not add any MySQL-write operations to blast scripts.\n\n## Workflow\n\n### 1. Clarify the request\n\nFrom the human's request, extract:\n- **Who** — recipient criteria (e.g. \"all active creators\", \"creators who earned > $1000\")\n- **What** — the message to send (subject + body)\n- **Reply-to** — default `support@gumroad.com`\n\n### 2. Draft the email\n\nWrite a `subject` and HTML `body`:\n- Simple HTML only: `<p>` tags, `<a>` links\n- Sign off: `<p>— The Gumroad Team</p>`\n- No `<ul>`/`<li>` (renders poorly). Use `<p>• Item</p>` for bullets.\n- Keep it short and direct\n- **Always use** `from: \"Gumroad <gumroad@creators.gumroad.com>\"` and `sender_domain: :creators`\n- The OneOffMailer template renders the subject as an `<h2>` heading at the top automatically\n\n**Present the draft for human approval before proceeding.**\n\n### 3. Count recipients via Metabase\n\nBuild a SQL query and validate the count:\n```bash\nMETABASE_KEY=$(op item get \"Metabase (Gumroad)\" --vault Gumclaw --fields \"API Key\" --reveal)\ncurl -s -X POST \"https://gumroad.metabaseapp.com/api/dataset\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-Metabase-Session: $METABASE_KEY\" \\\n  -d '{\n    \"database\": 2,\n    \"type\": \"native\",\n    \"native\": { \"query\": \"SELECT COUNT(*) FROM users WHERE deleted_at IS NULL AND is_suspended = 0 AND ...\" }\n  }' | python3 -c \"import json,sys; print(json.load(sys.stdin)['data']['rows'][0][0])\"\n```\n\n**Always filter**: `deleted_at IS NULL AND is_suspended = 0` (alive, not suspended).\n\nShow the count to the human before executing.\n\n### 4. Dry run (preview)\n\nGenerate a script that sends to just 1-2 test recipients first. Run via `prod_query.sh`:\n```bash\ncat << 'RUBY' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\ntest_user = User.find_by(email: \"sahil@gumroad.com\")\nOneOffMailer.email(\n  email: test_user.form_email,\n  from: \"Gumroad <gumroad@creators.gumroad.com>\",\n  subject: \"SUBJECT HERE\",\n  body: <<~HTML,\n    <p>Body here.</p>\n    <p>— The Gumroad Team</p>\n  HTML\n  reply_to: \"support@gumroad.com\",\n  sender_domain: :creators\n).deliver_later(queue: \"low\")\nputs \"Test email enqueued to #{test_user.form_email}\"\nRUBY\n```\n\n**Wait for human confirmation that the test email looks good before proceeding.**\n\n### 5. Execute the blast\n\nGenerate the full script with Redis checkpointing for resumability:\n\n```bash\ncat << 'RUBY' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\nredis_key = \"email_blast_DESCRIPTIVE_KEY_last_user_id\"\nlast_processed_id = $redis.get(redis_key).to_i\ncount = 0\n\nUser.alive.not_suspended.where(\"id > ?\", last_processed_id).where(CRITERIA).find_each(batch_size: 500) do |user|\n  next if user.form_email.blank?\n\n  OneOffMailer.email(\n    email: user.form_email,\n    from: \"Gumroad <gumroad@creators.gumroad.com>\",\n    subject: \"SUBJECT\",\n    body: <<~HTML,\n      <p>Body here.</p>\n      <p>— The Gumroad Team</p>\n    HTML\n    reply_to: \"support@gumroad.com\",\n    sender_domain: :creators\n  ).deliver_later(queue: \"low\")\n\n  $redis.set(redis_key, user.id)\n  count += 1\n  Rails.logger.info \"Enqueued email for user #{user.id} (#{count} total)\" if count % 100 == 0\nend\n\nputs \"Done. Enqueued #{count} emails.\"\nRUBY\n```\n\n### 6. Resume / Reset\n\n- **Resume** (if interrupted): Just re-run the same script. Redis checkpoint skips already-processed users.\n- **Reset** (to re-send): Delete the Redis key first:\n  ```bash\n  echo '$redis.del(\"email_blast_DESCRIPTIVE_KEY_last_user_id\")' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\n  ```\n\n## Safety Rules\n\n1. **Always get human approval** on subject, body, and recipient count before executing\n2. **Always send a dry-run** to a test recipient first (sahil@gumroad.com or the requester)\n3. **Always use Redis checkpointing** — blasts can be interrupted and resumed\n4. **Always use `deliver_later(queue: \"low\")`** — never `deliver_now` (would block the runner)\n5. **Always filter** `alive.not_suspended` and `form_email.present?`\n6. **Never send from** `hi@customers.gumroad.com` — that's for transactional emails only\n7. **Log progress** so you can verify emails are being enqueued\n\n## Key References\n\n- `OneOffMailer`: `app/mailers/one_off_mailer.rb`\n- Example service: `app/services/onetime/notify_sellers_about_legacy_fee_migration.rb`\n- `form_email`: The user's preferred contact email (may differ from login email)\n- Sender domains: `:creators` → `creators.gumroad.com`, default → `customers.gumroad.com`\n","frontmatter":{"name":"email-blast","description":"Send one-off email blasts to Gumroad creators directly via production console, no PR or deploy needed. Handles drafting, recipient query, count validation via Metabase, dry-run preview, and execution. Use when asked to \"email creators\", \"send an announcement\", \"blast sellers\", \"notify users\", or any request to send emails to Gumroad creators at scale.\n"},"isInternal":false,"tokens":1520,"sizeBytes":5486},{"name":"common-queries.md","path":".agents/skills/gumroad-prod-console/references/common-queries.md","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/gumroad-prod-console/references/common-queries.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# References Skill\n\nPath: `.agents/skills/gumroad-prod-console/references/common-queries.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/gumroad-prod-console/references/common-queries.md)","tokens":300,"sizeBytes":244},{"name":"SKILL.md","path":".agents/skills/gumroad-prod-console/SKILL.md","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/gumroad-prod-console/SKILL.md","title":"gumroad-prod-console","category":"anthropic-skill","format":"markdown","content":"---\nname: gumroad-prod-console\ndescription: >\n  Execute read-only Ruby/Rails commands against Gumroad's production database for debugging\n  and investigation. Use when the user needs to debug production issues, look up data,\n  investigate user reports, check records, or query production state. Triggers on: \"check in\n  prod\", \"debug this in prod\", \"look up user/purchase/product in production\", \"production\n  console\", \"investigate in prod\", \"query production\", \"what's happening in prod\", \"look up\n  a user by email\", \"who bought this product\", \"why is this user blocked\", \"find this\n  sale/purchase\", \"how many X does Y have\", or any request to examine live Gumroad data —\n  even when the user doesn't say \"prod\" explicitly.\n---\n\n# Gumroad production console\n\nRead-only Rails runner against the production read replica via bastion SSH.\n\n## Execution\n\n```bash\n.claude/skills/gumroad-prod-console/scripts/prod_query.sh 'puts User.count'\n.claude/skills/gumroad-prod-console/scripts/prod_query.sh /tmp/query.rb\necho 'puts User.count' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\n```\n\nMulti-line queries: write a temp `.rb` file, pass its path. Bash tool timeout is ~120s — wrap slow queries in `WithMaxExecutionTime.timeout_queries(seconds: 30) { ... }`. Queries run against the read replica (`DATABASE_WORKER_REPLICA1_HOST` by default).\n\nFollow-up queries are cheap — start scoped (one record, one field), then drill in as the investigation clarifies. Avoid the urge to return everything in a single large query.\n\nWarm hops: `prod_query.sh` multiplexes SSH to the bastion (`ControlPersist`) and reuses the last-good instance IP for 10 minutes (`PROD_IP_CACHE_TTL`). A second query should skip EC2 discovery. Pin with `PROD_INSTANCE_IP` to skip it entirely. Tracked in gumroad-private#2197.\n\nPersistent runner: the first replica-default query on a host boots a long-lived `rails runner` loop (`scripts/prod_runner_loop.rb`) inside the puma container; later queries are spooled to it and skip Rails boot entirely (~1s warm vs ~14s one-shot). The loop forks per query, keeps stdout/stderr separate, idles out after 30 minutes without work, and is replaced automatically when the local `prod_runner_loop.rb` changes. Non-default `PROD_DB_HOST_VAR` sessions (primary writes) always take the one-shot path; `PROD_NO_RUNNER_LOOP=1` opts out.\n\n## Safety\n\n- **Read-only.** Never write, update, or delete.\n- **No Redis `KEYS` or `FLUSH*`.** `prod_query.sh` prepends `scripts/redis_command_guard.rb`, which refuses them: one `KEYS` over the ~4.6M-key primary blocks it for seconds and 500s the site. Use `$redis.scan_each(match:, count: 1000).first(n)`.\n- Always `.limit()` / `.first()` / `.take()` — never unbounded result sets.\n- Prefer `.pluck(:col, :col)` over loading full AR objects.\n- Mask PII in output (truncate emails, addresses, payment details).\n- `.explain` before querying large tables without indexed conditions.\n- Emit structured output (JSON for complex, `.inspect` for simple) so results are parseable.\n\n## Gumroad-specific gotchas\n\n### External IDs, not primary keys\n\nAdmin URLs and public IDs use **external IDs** (`ExternalId` module — Base64 strings like `aBcDeFgHiJkLmNoPqRsTuQ==`), not integer PKs.\n\n```ruby\nPurchase.find_by_external_id(\"aBcDeFgHiJkLmNoPqRsTuQ==\")  # correct\nPurchase.find(\"aBcDeFgHiJkLmNoPqRsTuQ==\")                  # WRONG — treats it as a PK, silently returns the wrong record\n```\n\n### Model naming\n\n| Model | Note |\n|---|---|\n| `Link` | The product model (legacy name). `find_by(unique_permalink:)` for permalinks; `alive` scope for non-deleted. |\n| `Installment` | Subscriptions/recurring (not ActiveRecord's sense of \"installment\"). `where(link_id:, alive: true)`. |\n| `Comment` | Admin notes on records. `content` field, **not** `body`. |\n| `MerchantAccount` | Processor-specific — users can have multiple. `where(user_id:, charge_processor_id:)`. |\n| `Purchase` | `successful` scope for completed sales. `where(email:)`, `where(link_id:)`. |\n\n`User`, `Balance`, `Dispute`, `Follower`, `CustomDomain` behave as names suggest.\n\n### Sidekiq queues\n\n`critical` (12k limit — payouts, webhooks, receipts) → `default` (300k — general) → `long` (PDF stamping etc.) → `low` (expiry). Queue limits at `app/controllers/healthcheck_controller.rb:28`; worker ordering at `docker/web/sidekiq_worker.sh`.\n\n### DevTools (Gumroad-specific helpers)\n\nSee `lib/utilities/dev_tools.rb`:\n\n- `DevTools.reindex_all_for_user(user_id)` — reindex ES data\n- `DevTools.reimport_follower_events_for_user!(user)` — reimport follower analytics\n\nFor Gumroad-specific scopes and associations (`alive`, `successful`, `unpaid_balance_cents`, `payments`, `products`, Flipper checks, Sidekiq introspection), see [references/common-queries.md](references/common-queries.md).\n\n### Stale bastion host keys look like unhealthy instances\n\nEC2 recycles private IPs, so the **bastion's** `known_hosts` accumulates stale keys and refuses the onward hop with `REMOTE HOST IDENTIFICATION HAS CHANGED` / `Offending ECDSA key`. From outside this is easy to mistake for a hung instance, and it silently shrinks the usable pool each time instances are replaced.\n\n`prod_query.sh` catches them without touching the pin: a candidate is recorded as stale-keyed when a changed-key complaint names its address — including on a probe that looked healthy, since the forced command exits 0 after its own hop refused — and the run reports those addresses with the one-hop `ssh-keygen -R` command to run by hand.\n\n- **The pin is never removed automatically.** Every textbook trigger for a removal is something a pin exists to defend against: EC2 membership says the address is a pool member, not that the key it serves is the instance's, and a changed-key complaint is an alarm rather than proof of a recycle — deleting the entry and reconnecting pins whatever answered. Clearing is an explicit operator action, printed as a one-hop command.\n- **Only genuinely outdated keys are reported.** A plain timeout, a container that is still starting, or a network blip leaves the recorded key alone — only SSH naming that address counts. The onward hop usually just *warns* about a changed key and connects anyway (recycled IPs make that the steady state), so a warn-and-proceed failure stays on the patient-retry list too. Only an outright `Host key verification failed` refusal drops a candidate from the retry.\n- **A pool rejected entirely for stale keys says so**, with that same command, instead of the generic health-probe error.\n\n- **`LC_PAPER=127.0.0.1` means the bastion itself.** The forced command jumps to whatever `LC_PAPER` names, and omitting the variable — or the `-o SendEnv=LC_PAPER` that ships it — fails with `ssh: Could not resolve hostname`; instance IPs run the command on that instance. Anything aimed at the bastion's own files goes through `127.0.0.1`.\n- **The warning text is often noise.** A run can print the whole man-in-the-middle banner for a *previous* hop and still succeed. Judge by exit code and `MARK` output, not the banner. `rc 255` is the transport dropping mid-flight, so the outcome is unknown — do not read it as a failed query. Any *other* nonzero rc whose only output is the banner means the query failed: look for `Query process killed by SIGKILL` or the query's own output.\n\n### \"No instance passed the health probe\" usually means slow, not down\n\nThe candidate probe is deliberately impatient (20s) so one hung host cannot eat the caller's budget, but that also rejects hosts which are merely slow under load. When *every* candidate fails, a fleet-wide outage is the less likely reading.\n\n`prod_query.sh` now retries the non-stale-key rejections with a patient probe before giving up, and says so (`answered on the patient retry (slow, not unhealthy)`). Only the all-rejected path pays for this.\n\nBoth passes draw down one shared **90s selection budget** (`PROD_SELECT_BUDGET`), so picking a host can never consume the caller's whole ~120s Bash-tool window before the query starts — a large pool shortens each probe rather than adding to the total. A third of the budget is held back for the patient pass, so a pool of slow hosts cannot drain everything in the fast pass and starve the retry. Running out gives a distinct message that says the pool may just be slow, instead of the misleading \"no instance passed the health probe\". Raise the budget when you have more wall clock than the Bash tool allows:\n\n```bash\nPROD_SELECT_BUDGET=240 timeout 560 bash .agents/skills/gumroad-prod-console/scripts/prod_query.sh q.rb\n```\n\nIf you still get the hard error, force a host rather than assuming production is down:\n\n```bash\nPROD_INSTANCE_IP=10.1.34.180 bash .agents/skills/gumroad-prod-console/scripts/prod_query.sh q.rb\n```\n\nThis matters for **watchers**, not just interactive use: a cron that treats an unreadable console as \"nothing to report\" goes silent indefinitely and looks healthy. On 2026-07-29 all 8 candidates were rejected while a forced host answered the same query in 13s.\n\n### Grepping only for `^MARK` can hide a total failure\n\n`... | grep \"^MARK\"` on a run that never reached Rails returns empty with **exit 0**, which reads as \"query worked, no rows\". Capture to files and echo the exit code:\n\n```bash\ntimeout 560 prod_query.sh /tmp/q.rb > /tmp/q.out 2>/tmp/q.err; echo \"exit=$?\"\ngrep -a \"^MARK\" /tmp/q.out\n```\n\nExit `124` = outer timeout (query too heavy). `255` = SSH itself failed: either the hop dropped before Rails started, or the transport dropped mid-flight with the spool state unknown — treat the query's outcome as unknown rather than failed.\n\n### `association.unscoped` reads the whole table\n\n`Link` has no `default_scope`, so `user.links` already includes deleted rows — there is nothing to reach for `.unscoped` for. On an association it drops the `user_id` condition instead, leaving `SELECT * FROM links` (millions of rows). Iterating that gets the query process OOM-killed: the run exits nonzero, prints `Query process killed by SIGKILL`, and the stale host-key banner above it is **not** the cause. Use `user.links` (or `Link.where(user_id: user.id)`).\n\n### Keep Stripe API calls out of loops\n\nThe DB is fast; `Stripe::Transfer.list` / `Charge.retrieve` per record is what blows the timeout — even ~5 accounts with one Stripe call each can hit exit `124`. Use raw SQL via `ActiveRecord::Base.connection.select_all` for population questions, and fetch at most 1–2 Stripe objects per run when you need live processor state.\n\n## Requirements\n\n- An AWS profile with `ec2:DescribeInstances` on the prod account. The script defaults to the profile `gumroad-prod` (created by `scripts/setup.sh`). Override by exporting `AWS_PROFILE` or setting `PROD_AWS_PROFILE` in your config file.\n- SSH access to your production bastion (defaults to `bastion-production.gumroad.net`)\n\n### Gumroad team one-time setup\n\nIf you previously ran this script via `GUMROAD_DEPLOYMENT_DIR` and `.env.aws`, run the helper from the repo root to migrate those creds into an AWS CLI profile:\n\n```bash\n.claude/skills/gumroad-prod-console/scripts/setup.sh\n# or pass an explicit path:\n.claude/skills/gumroad-prod-console/scripts/setup.sh ~/path/to/.env.aws\n```\n\nThe helper will also offer to append `export AWS_PROFILE=gumroad-prod` to your shell profile — say yes and reload the shell. After this, `gumroad-private` is no longer required to run the skill.\n\n## Configuration (self-hosters)\n\nDefaults target Gumroad's prod infra. If you're running your own Gumroad fork, override by creating `~/.config/gumroad-prod-console.env`:\n\n```bash\nPROD_BASTION=bastion.mycompany.com\nPROD_SECURITY_GROUP=my-web-sg\nPROD_CONTAINER_FILTER=app-*\nPROD_DB_HOST_VAR=MY_READ_REPLICA_HOST\nPROD_AWS_PROFILE=my-aws-profile\n```\n\nOr export the same variables before invoking the script.\n","frontmatter":{"name":"gumroad-prod-console","description":"Execute read-only Ruby/Rails commands against Gumroad's production database for debugging and investigation. Use when the user needs to debug production issues, look up data, investigate user reports, check records, or query production state. Triggers on: \"check in prod\", \"debug this in prod\", \"look up user/purchase/product in production\", \"production console\", \"investigate in prod\", \"query production\", \"what's happening in prod\", \"look up a user by email\", \"who bought this product\", \"why is this user blocked\", \"find this sale/purchase\", \"how many X does Y have\", or any request to examine live Gumroad data — even when the user doesn't say \"prod\" explicitly.\n"},"isInternal":false,"tokens":2892,"sizeBytes":11844},{"name":"review-guidance.md","path":".agents/skills/review-pr/references/review-guidance.md","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/review-pr/references/review-guidance.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# References Skill\n\nPath: `.agents/skills/review-pr/references/review-guidance.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/review-pr/references/review-guidance.md)","tokens":300,"sizeBytes":224},{"name":"SKILL.md","path":".agents/skills/review-pr/SKILL.md","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/review-pr/SKILL.md","title":"review-pr","category":"anthropic-skill","format":"markdown","content":"---\nname: review-pr\ndescription: >\n  Review GitHub pull requests for the Gumroad codebase against project guidelines, code quality,\n  and correctness. Use when the user wants to review a PR, check new comments on a PR, provide\n  feedback on code changes, or asks about PR quality. Triggers on: \"review PR\", \"review this PR\",\n  \"check PR #NNN\", any GitHub PR URL (e.g. https://github.com/antiwork/gumroad/pull/NNNN),\n  \"what's new on the PR\", \"check comments\", \"review the diff\", \"give feedback on this PR\",\n  or requests to evaluate code changes in a pull request.\n---\n\n# PR Review\n\nReview pull requests for code quality, correctness, and CONTRIBUTING.md compliance.\n\n**Scope boundary**: This skill evaluates _how the code is written_. For evaluating _whether the PR solves the right problem_, use the `issue-detective` skill instead.\n\n## Workflow\n\n### 1. Fetch PR Context\n\n```bash\ngh pr view <number> --repo antiwork/gumroad\ngh pr diff <number> --repo antiwork/gumroad\ngh pr view <number> --repo antiwork/gumroad --comments\ngh api repos/antiwork/gumroad/pulls/<number>/comments\n```\n\nAlso read `CONTRIBUTING.md` from the repo root — it's the source of truth for all guideline checks.\n\nNote PR status (draft, closed, merged). Skip review if merged or closed unless user insists.\n\n### 2. Understand the Change\n\nFrom the diff and description, determine:\n\n- What changed and why (read the linked issue if referenced)\n- Bug fix, feature, refactor, or chore\n- Which layers are affected (frontend, backend, both, tests only)\n- Size and complexity\n\nRead key modified files in full when the diff alone is insufficient to understand context.\n\n### 3. Review\n\nRun these review passes on the diff:\n\n**Pass 1 — Bugs and Logic Errors**\nWrong conditionals, missing edge cases, race conditions, nil/null handling, off-by-one errors, security vulnerabilities (injection, XSS, CSRF). Focus on code paths introduced or modified by the PR.\n\n**Pass 2 — CONTRIBUTING.md Compliance**\nCheck the diff against every applicable rule in CONTRIBUTING.md (code standards, naming conventions, testing standards, Sidekiq patterns, PR structure, etc.). The file is the single source of truth — do not maintain a separate checklist.\n\n**Pass 3 — Code Clarity**\nEvaluate readability and maintainability of new/modified code. See [references/review-guidance.md](references/review-guidance.md) for what to flag vs what to leave alone. The goal is clear, explicit code — not clever or compact code.\n\n**Pass 4 — PR Structure**\nAI disclosure, description quality (explains _why_), before/after media, test results, appropriate size — all per CONTRIBUTING.md.\n\n**⛔ Media evidence is a BLOCKING check (CONTRIBUTING.md line 17 — a hard `Must`).** If the PR touches product/UI (any view, component, CSS, layout, mobile behavior, user-visible copy) and has **no before/after video or screenshots**, raise it as a **critical** finding at **confidence 95+** and set the Verdict to request-changes. Do NOT wave through a mobile/CSS/layout PR just because the prose description is clear — \"the code looks right\" is not a substitute for visual proof. A missing video on a non-visual change (no walkthrough) is at least an **important** finding. This does not apply to PRs that only touch documentation or agent skill files — the diff itself is the reviewable artifact, so do not raise a missing-media finding for docs- or skill-only PRs.\n\n### 4. Score and Filter\n\nAssign each finding a confidence score (0–100): how likely is this a genuine problem?\n\n**Keep** findings >= 80. **Drop** findings below 80.\n\nSee [references/review-guidance.md](references/review-guidance.md) for noise filtering rules and severity levels.\n\n### 5. Report\n\n```\n## Summary\n\n[1-2 sentences: what the PR does and overall assessment]\n\n## Issues\n\n### [critical|important|suggestion] Title\n**File:** `path/to/file.rb:NN`\n**Confidence:** NN/100\n\n[Concise explanation and fix suggestion if straightforward.]\n\n...\n\n## Checklist\n\n[CONTRIBUTING.md items that are missing — e.g., no AI disclosure, no before/after, missing tests]\n\n## Verdict\n\n[approve / request-changes / comment-only — with brief justification]\n```\n\nWrite the review to `gh-pr-review.md` in the repo root. Do NOT stage or commit. Do NOT post to GitHub.\n\n### 6. Iterate\n\nWhen the user asks to re-check a PR (new comments, updated diff):\n\n```bash\ngh pr view <number> --repo antiwork/gumroad --comments\ngh api repos/antiwork/gumroad/pulls/<number>/comments\ngh pr diff <number> --repo antiwork/gumroad\n```\n\nFocus on what changed since the last review. Don't repeat prior findings unless still unaddressed.\n\n## Important\n\n- Use `gh` read-only only. Never approve, comment on, or request changes via CLI.\n- Review what the PR _introduces_ — not pre-existing code.\n- Be direct and concise. No filler praise.\n- Prioritize substantive issues over cosmetic ones.\n- When unsure, state uncertainty rather than false confidence.\n- For large PRs (1k+ lines), note the PR should be broken up.\n","frontmatter":{"name":"review-pr","description":"Review GitHub pull requests for the Gumroad codebase against project guidelines, code quality, and correctness. Use when the user wants to review a PR, check new comments on a PR, provide feedback on code changes, or asks about PR quality. Triggers on: \"review PR\", \"review this PR\", \"check PR #NNN\", any GitHub PR URL (e.g. https://github.com/antiwork/gumroad/pull/NNNN), \"what's new on the PR\", \"check comments\", \"review the diff\", \"give feedback on this PR\", or requests to evaluate code changes in a pull request.\n"},"isInternal":false,"tokens":1192,"sizeBytes":4992},{"name":"SKILL.md","path":".agents/skills/test-confidence/SKILL.md","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/test-confidence/SKILL.md","title":"test-confidence","category":"anthropic-skill","format":"markdown","content":"---\nname: test-confidence\ndescription: AI-driven test execution. Opus decides what to run and how confident to be, based on your diff.\nargument-hint: \"--full to run to 100% | --strict to halt on pre-existing failures\"\nallowed-tools: Bash(git *), Bash(bundle exec rspec *), Bash(cat *), Bash(find *), Bash(wc *), Bash(head *), Bash(tail *), Bash(grep *), Bash(bin/test-confidence *)\n---\n\n# Test Confidence\n\nRun `bin/test-confidence` to have Opus 4.7 analyze your diff, decide the risk level, plan which tests to run and in what order, and set confidence milestones. The AI decides the shape of the curve based on this specific diff.\n\n## Usage\n\n```bash\nbin/test-confidence            # Run to 99%, stop. Skips past pre-existing failures.\nbin/test-confidence --full     # Run to 100%\nbin/test-confidence --strict   # Halt on any failure, including pre-existing\n```\n\n`ANTHROPIC_API_KEY` is auto-sourced from `.env` if not exported.\n\nIf `$ARGUMENTS` is provided, pass it through: `bin/test-confidence $ARGUMENTS`\n\n## How it works\n\n1. Finds changed files (branch diff vs main for PR branches, local diff on main)\n2. Hashes the diff and checks `tmp/test-confidence/` for a cached plan; reuses if found\n3. Otherwise sends the diff + spec tree + touched directories to Opus 4.7 in one call\n4. Opus returns a plan: risk level, ordered test list, confidence milestones\n5. Script executes the plan, showing yellow progress bar toward 99%\n6. At 99%, bar turns green. Safe to commit.\n7. With `--full`, continues running remaining tests toward 100%\n\n## Pre-existing failure detection\n\nWhen a spec fails, the script applies a two-step check:\n\n1. **Path heuristic** — does the failing spec file or its source counterpart (e.g., `app/models/user.rb` for `spec/models/user_spec.rb`) appear in the diff?\n   - **In the diff** → real regression. Halt immediately.\n   - **Not in the diff** → suspect pre-existing. Verify on merge-base.\n2. **Merge-base verify** — re-run the failing rspec examples in a temporary worktree at the branch's merge-base with `main`.\n   - **Still fails on merge-base** → confirmed pre-existing. Continue.\n   - **Passes on merge-base** → cross-file regression caught. Halt.\n\nThe verify step is skipped (heuristic verdict trusted) when the diff includes a `db/migrate/*.rb` (DB schema drift) or `Gemfile.lock` (bundler drift), or when no merge-base is available. `--strict` skips both checks and halts on every failure.\n\nCost profile: zero overhead in the common no-failure case; verify only runs on heuristic-flagged \"pre-existing\" failures. Catches the dangerous direction (silent regression skipped) while keeping false-alarm investigation costs bounded.\n\nThe key insight: Opus decides ad hoc how many tests are needed for each confidence level. A comment-only change might need 2 tests for 99%. A payment model refactor might need 100.\n\n## When to use\n\nRun this before every commit. It replaces manually picking which specs to run.\n\n$ARGUMENTS\n","frontmatter":{"name":"test-confidence","description":"AI-driven test execution. Opus decides what to run and how confident to be, based on your diff.","argument-hint":"--full to run to 100% | --strict to halt on pre-existing failures","allowed-tools":"Bash(git *), Bash(bundle exec rspec *), Bash(cat *), Bash(find *), Bash(wc *), Bash(head *), Bash(tail *), Bash(grep *), Bash(bin/test-confidence *)","allowedTools":["Bash(git","*),","Bash(bundle","exec","rspec","*),","Bash(cat","*),","Bash(find","*),","Bash(wc","*),","Bash(head","*),","Bash(tail","*),","Bash(grep","*),","Bash(bin/test-confidence","*)"]},"allowedTools":["Bash(git","*),","Bash(bundle","exec","rspec","*),","Bash(cat","*),","Bash(find","*),","Bash(wc","*),","Bash(head","*),","Bash(tail","*),","Bash(grep","*),","Bash(bin/test-confidence","*)"],"isInternal":false,"tokens":714,"sizeBytes":2962},{"name":"commit","path":".claude/skills/commit","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/commit","title":"Claude Directive: commit","category":"claude-rule","format":"markdown","content":"../../.agents/skills/commit","isInternal":false,"tokens":7,"sizeBytes":27},{"name":"create-issue","path":".claude/skills/create-issue","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/create-issue","title":"Claude Directive: create-issue","category":"claude-rule","format":"markdown","content":"../../.agents/skills/create-issue","isInternal":false,"tokens":7,"sizeBytes":33},{"name":"email-blast","path":".claude/skills/email-blast","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/email-blast","title":"Claude Directive: email-blast","category":"claude-rule","format":"markdown","content":"../../.agents/skills/email-blast","isInternal":false,"tokens":8,"sizeBytes":32},{"name":"gumroad-prod-console","path":".claude/skills/gumroad-prod-console","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/gumroad-prod-console","title":"Claude Directive: gumroad-prod-console","category":"claude-rule","format":"markdown","content":"../../.agents/skills/gumroad-prod-console","isInternal":false,"tokens":11,"sizeBytes":41},{"name":"review-pr","path":".claude/skills/review-pr","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/review-pr","title":"Claude Directive: review-pr","category":"claude-rule","format":"markdown","content":"# Claude Directive: review-pr\n\nPath: `.claude/skills/review-pr`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/review-pr)","tokens":300,"sizeBytes":175},{"name":"test-confidence","path":".claude/skills/test-confidence","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/test-confidence","title":"Claude Directive: test-confidence","category":"claude-rule","format":"markdown","content":"# Claude Directive: test-confidence\n\nPath: `.claude/skills/test-confidence`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/test-confidence)","tokens":300,"sizeBytes":193},{"name":"copilot-instructions.md","path":".github/copilot-instructions.md","rawUrl":"https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.github/copilot-instructions.md","title":"GitHub Copilot Instructions","category":"copilot-instructions","format":"markdown","content":"See CONTRIBUTING.md for development guidelines\n","isInternal":false,"tokens":8,"sizeBytes":47}],"systemPromptSnippet":"<yakaai_skills repo=\"antiwork/gumroad\">\n<!-- File: AGENTS.md (Tokens: ~227 | Category: root-instruction) -->\n@CONTRIBUTING.md\n\n## Agent Skills\n\nProject skills live in `.agents/skills/<name>/SKILL.md`. The `.claude/skills/<name>` entries are symlinks for Claude Code compatibility. When adding or updating a project skill, edit `.agents/skills` and keep the matching Claude symlink in place.\n\n## Code comments\n\nWrite comments for a reader who already knows this codebase. They are a colleague, not a\nstudent — so say the one thing the code cannot say, and stop.\n\nKeep: the non-obvious *why*, ordering constraints, and traps that will bite the next person\nto edit this. Cut: vendor or company history, anything restating the line below it, narration\nof how the bug was found, and re-explanations of a named constant the reader can click\nthrough to. If a comment runs past about three lines, ask what you would delete.\n\nA good test: would a maintainer who knows this file find this line worth reading? If not, it\nis noise — and noise is what teaches people to skip the comments that matter.\n\n<!-- File: .agents/skills/commit/SKILL.md (Tokens: ~527 | Category: anthropic-skill) -->\n---\nname: commit\ndescription: Stage and commit changes with a clear, concise commit message.\nargument-hint: [optional message hint]\nallowed-tools: Bash(git *)\n---\n\n# Commit\n\nCreate a commit for the current changes.\n\n## Steps\n\n1. Run `git status` (without `-uall`) to see untracked and modified files.\n2. Run `git diff` and `git diff --cached` to understand staged and unstaged changes.\n3. Run `git log --oneline -5` to see recent commit style.\n4. **Run `bin/test-confidence` to verify your changes don't break anything.** Opus analyzes your diff and runs the right tests. Wait for 99% confidence (green bar) before proceeding. If any test fails, fix it first.\n5. **Run linters on changed files before staging:**\n   - Ruby files: `bundle exec rubocop --force-exclusion -a <files>`\n   - JS/TS files: `npm run lint-fast -- --max-warnings 0 --fix --no-warn-ignored <files>`\n   - TypeScript type check: `npx tsc --noEmit` (required, catches type errors that eslint skips)\n   - CSS/SCSS/JSON/MD files: `npx prettier --write <files>`\n   - SVG files: `npx svgo --multipass <files>`\n   - Fix any lint errors or type errors before proceeding. Do not commit code that fails linting or type checking.\n6. Stage the relevant files by name — avoid `git add -A` or `git add .`.\n7. Write a commit message and commit.\n\nIf `$ARGUMENTS` is provided, use it as a hint for the message.\n\n## Commit message rules\n\n- Use the imperative mood (\"Add\", \"Fix\", \"Remove\", not \"Added\", \"Fixes\", \"Removed\").\n- Be concise: one short sentence, ideally under 50 characters.\n- Do NOT use conventional commit prefixes (no `feat:`, `fix:`, `chore:`, etc.).\n- Do NOT add any `Co-Authored-By` or similar trailers.\n- Focus on **what** changed and **why**, not how.\n- If a second line is needed for context, keep it brief.\n\n## Examples of good messages\n\n- `Prevent discount code from being cleared on edit`\n- `Add integration test for offer code persistence`\n- `Remove unused legacy export helper`\n- `Fix thumbnail missing in upsell insert`\n\n$ARGUMENTS\n\n<!-- File: .agents/skills/create-issue/SKILL.md (Tokens: ~677 | Category: anthropic-skill) -->\n---\nname: create-issue\ndescription: >\n  Draft GitHub issues for the Gumroad codebase. Outputs to an unstaged markdown file in the repo\n  root. Never creates issues on GitHub. Triggers on: \"create issue\", \"write issue\", \"draft issue\",\n  \"write a bug report\", \"file an issue\", \"create a ticket\", or when the user describes a problem\n  or feature they want to turn into a GitHub issue.\n---\n\n# Issue Drafting\n\nDraft well-structured GitHub issues. Output to an unstaged `.md` file — never post to GitHub.\n\n## Workflow\n\n### 1. Understand the Problem\n\nGather context from the user. Ask clarifying questions if needed:\n\n- What's happening vs what should happen?\n- Who's affected and how often?\n- Any workarounds in use?\n- Is there data that quantifies the impact?\n\nIf the user points to code, trace the relevant paths to understand the root cause. Include aggregated, anonymized data that quantifies the impact when available (e.g., \"99 purchases across 82 sellers are in this state\"). Never include PII — no emails, names, IDs, or payment details.\n\n### 2. Decide: Single Issue or Parent + Sub-issues\n\nBefore writing, assess the scope:\n\n**Single issue** when:\n\n- One clear problem with one solution path\n- The fix would be one PR (~100 LOC per CONTRIBUTING.md)\n- No independent workstreams\n\n**Parent + sub-issues** when:\n\n- The solution has multiple independently shippable parts\n- Different parts could be worked on in parallel or by different people\n- The total scope would exceed one reasonably-sized PR\n- There's a logical sequence (e.g., API integration → webhook fix → backfill)\n\nIf suggesting sub-issues, number the solution parts clearly and note which can be done independently vs which depend on each other. Write the parent issue as the full document, then note where to split.\n\n### 3. Write the Issue\n\nFollow the structure in CONTRIBUTING.md.\n\n**Title** — Carries most of the weight. Specific and actionable.\n\n**## What**\nWhat needs to change. Be concrete — current vs desired behavior, who is affected, checkbox task list for multiple deliverables.\n\n**## Why**\nWhy this matters — business rationale or user impact, root cause if it's a bug, links to related issues or prior discussions.\n\n### 4. Output\n\nWrite to `gh-issue-draft.md` in the repo root. Do NOT stage or commit.\n\nIf the issue should be split into sub-issues, write the parent issue first, then add a `## Sub-issues` section at the end listing each sub-issue with a one-line summary. The user can split them manually.\n\n## Style\n\nFollow CONTRIBUTING.md for terminology and tone. Additionally:\n\n- Concise and direct. No jargon, no filler.\n- Ground claims in data or code references.\n- Reference code by area or concept (\"the dispute webhook handler\"), not by file:line.\n- The issue is public (open source). Self-contained for OSS contributors with no access to internal tools.\n\n## Important\n\n- Use `gh` read-only only. Never create issues via CLI.\n- The user reviews and posts manually — just write the best possible draft.\n\n<!-- File: .agents/skills/email-blast/SKILL.md (Tokens: ~1520 | Category: anthropic-skill) -->\n---\nname: email-blast\ndescription: >\n  Send one-off email blasts to Gumroad creators directly via production console, no PR or\n  deploy needed. Handles drafting, recipient query, count validation via Metabase, dry-run\n  preview, and execution. Use when asked to \"email creators\", \"send an announcement\",\n  \"blast sellers\", \"notify users\", or any request to send emails to Gumroad creators at scale.\n---\n\n# Email Blast\n\nSend one-off emails to Gumroad creators by executing Ruby inline against the production console. No PR, no deploy, no service class needed.\n\n## Prerequisites\n\n- Production console access configured via the `gumroad-prod-console` skill (see its SKILL.md for setup)\n- Metabase API key (in 1Password: \"Metabase (Gumroad)\" → \"API Key\")\n\nAll production Ruby execution in this skill goes through `.claude/skills/gumroad-prod-console/scripts/prod_query.sh`. That script targets the read replica for MySQL, which is sufficient here — `User.alive.not_suspended...find_each` is a read, and `deliver_later` + `$redis.set` hit Redis, not MySQL. Do not add any MySQL-write operations to blast scripts.\n\n## Workflow\n\n### 1. Clarify the request\n\nFrom the human's request, extract:\n- **Who** — recipient criteria (e.g. \"all active creators\", \"creators who earned > $1000\")\n- **What** — the message to send (subject + body)\n- **Reply-to** — default `support@gumroad.com`\n\n### 2. Draft the email\n\nWrite a `subject` and HTML `body`:\n- Simple HTML only: `<p>` tags, `<a>` links\n- Sign off: `<p>— The Gumroad Team</p>`\n- No `<ul>`/`<li>` (renders poorly). Use `<p>• Item</p>` for bullets.\n- Keep it short and direct\n- **Always use** `from: \"Gumroad <gumroad@creators.gumroad.com>\"` and `sender_domain: :creators`\n- The OneOffMailer template renders the subject as an `<h2>` heading at the top automatically\n\n**Present the draft for human approval before proceeding.**\n\n### 3. Count recipients via Metabase\n\nBuild a SQL query and validate the count:\n```bash\nMETABASE_KEY=$(op item get \"Metabase (Gumroad)\" --vault Gumclaw --fields \"API Key\" --reveal)\ncurl -s -X POST \"https://gumroad.metabaseapp.com/api/dataset\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-Metabase-Session: $METABASE_KEY\" \\\n  -d '{\n    \"database\": 2,\n    \"type\": \"native\",\n    \"native\": { \"query\": \"SELECT COUNT(*) FROM users WHERE deleted_at IS NULL AND is_suspended = 0 AND ...\" }\n  }' | python3 -c \"import json,sys; print(json.load(sys.stdin)['data']['rows'][0][0])\"\n```\n\n**Always filter**: `deleted_at IS NULL AND is_suspended = 0` (alive, not suspended).\n\nShow the count to the human before executing.\n\n### 4. Dry run (preview)\n\nGenerate a script that sends to just 1-2 test recipients first. Run via `prod_query.sh`:\n```bash\ncat << 'RUBY' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\ntest_user = User.find_by(email: \"sahil@gumroad.com\")\nOneOffMailer.email(\n  email: test_user.form_email,\n  from: \"Gumroad <gumroad@creators.gumroad.com>\",\n  subject: \"SUBJECT HERE\",\n  body: <<~HTML,\n    <p>Body here.</p>\n    <p>— The Gumroad Team</p>\n  HTML\n  reply_to: \"support@gumroad.com\",\n  sender_domain: :creators\n).deliver_later(queue: \"low\")\nputs \"Test email enqueued to #{test_user.form_email}\"\nRUBY\n```\n\n**Wait for human confirmation that the test email looks good before proceeding.**\n\n### 5. Execute the blast\n\nGenerate the full script with Redis checkpointing for resumability:\n\n```bash\ncat << 'RUBY' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\nredis_key = \"email_blast_DESCRIPTIVE_KEY_last_user_id\"\nlast_processed_id = $redis.get(redis_key).to_i\ncount = 0\n\nUser.alive.not_suspended.where(\"id > ?\", last_processed_id).where(CRITERIA).find_each(batch_size: 500) do |user|\n  next if user.form_email.blank?\n\n  OneOffMailer.email(\n    email: user.form_email,\n    from: \"Gumroad <gumroad@creators.gumroad.com>\",\n    subject: \"SUBJECT\",\n    body: <<~HTML,\n      <p>Body here.</p>\n      <p>— The Gumroad Team</p>\n    HTML\n    reply_to: \"support@gumroad.com\",\n    sender_domain: :creators\n  ).deliver_later(queue: \"low\")\n\n  $redis.set(redis_key, user.id)\n  count += 1\n  Rails.logger.info \"Enqueued email for user #{user.id} (#{count} total)\" if count % 100 == 0\nend\n\nputs \"Done. Enqueued #{count} emails.\"\nRUBY\n```\n\n### 6. Resume / Reset\n\n- **Resume** (if interrupted): Just re-run the same script. Redis checkpoint skips already-processed users.\n- **Reset** (to re-send): Delete the Redis key first:\n  ```bash\n  echo '$redis.del(\"email_blast_DESCRIPTIVE_KEY_last_user_id\")' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\n  ```\n\n## Safety Rules\n\n1. **Always get human approval** on subject, body, and recipient count before executing\n2. **Always send a dry-run** to a test recipient first (sahil@gumroad.com or the requester)\n3. **Always use Redis checkpointing** — blasts can be interrupted and resumed\n4. **Always use `deliver_later(queue: \"low\")`** — never `deliver_now` (would block the runner)\n5. **Always filter** `alive.not_suspended` and `form_email.present?`\n6. **Never send from** `hi@customers.gumroad.com` — that's for transactional emails only\n7. **Log progress** so you can verify emails are being enqueued\n\n## Key References\n\n- `OneOffMailer`: `app/mailers/one_off_mailer.rb`\n- Example service: `app/services/onetime/notify_sellers_about_legacy_fee_migration.rb`\n- `form_email`: The user's preferred contact email (may differ from login email)\n- Sender domains: `:creators` → `creators.gumroad.com`, default → `customers.gumroad.com`\n\n<!-- File: .agents/skills/gumroad-prod-console/references/common-queries.md (Tokens: ~300 | Category: anthropic-skill) -->\n# References Skill\n\nPath: `.agents/skills/gumroad-prod-console/references/common-queries.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/gumroad-prod-console/references/common-queries.md)\n\n<!-- File: .agents/skills/gumroad-prod-console/SKILL.md (Tokens: ~2892 | Category: anthropic-skill) -->\n---\nname: gumroad-prod-console\ndescription: >\n  Execute read-only Ruby/Rails commands against Gumroad's production database for debugging\n  and investigation. Use when the user needs to debug production issues, look up data,\n  investigate user reports, check records, or query production state. Triggers on: \"check in\n  prod\", \"debug this in prod\", \"look up user/purchase/product in production\", \"production\n  console\", \"investigate in prod\", \"query production\", \"what's happening in prod\", \"look up\n  a user by email\", \"who bought this product\", \"why is this user blocked\", \"find this\n  sale/purchase\", \"how many X does Y have\", or any request to examine live Gumroad data —\n  even when the user doesn't say \"prod\" explicitly.\n---\n\n# Gumroad production console\n\nRead-only Rails runner against the production read replica via bastion SSH.\n\n## Execution\n\n```bash\n.claude/skills/gumroad-prod-console/scripts/prod_query.sh 'puts User.count'\n.claude/skills/gumroad-prod-console/scripts/prod_query.sh /tmp/query.rb\necho 'puts User.count' | .claude/skills/gumroad-prod-console/scripts/prod_query.sh\n```\n\nMulti-line queries: write a temp `.rb` file, pass its path. Bash tool timeout is ~120s — wrap slow queries in `WithMaxExecutionTime.timeout_queries(seconds: 30) { ... }`. Queries run against the read replica (`DATABASE_WORKER_REPLICA1_HOST` by default).\n\nFollow-up queries are cheap — start scoped (one record, one field), then drill in as the investigation clarifies. Avoid the urge to return everything in a single large query.\n\nWarm hops: `prod_query.sh` multiplexes SSH to the bastion (`ControlPersist`) and reuses the last-good instance IP for 10 minutes (`PROD_IP_CACHE_TTL`). A second query should skip EC2 discovery. Pin with `PROD_INSTANCE_IP` to skip it entirely. Tracked in gumroad-private#2197.\n\nPersistent runner: the first replica-default query on a host boots a long-lived `rails runner` loop (`scripts/prod_runner_loop.rb`) inside the puma container; later queries are spooled to it and skip Rails boot entirely (~1s warm vs ~14s one-shot). The loop forks per query, keeps stdout/stderr separate, idles out after 30 minutes without work, and is replaced automatically when the local `prod_runner_loop.rb` changes. Non-default `PROD_DB_HOST_VAR` sessions (primary writes) always take the one-shot path; `PROD_NO_RUNNER_LOOP=1` opts out.\n\n## Safety\n\n- **Read-only.** Never write, update, or delete.\n- **No Redis `KEYS` or `FLUSH*`.** `prod_query.sh` prepends `scripts/redis_command_guard.rb`, which refuses them: one `KEYS` over the ~4.6M-key primary blocks it for seconds and 500s the site. Use `$redis.scan_each(match:, count: 1000).first(n)`.\n- Always `.limit()` / `.first()` / `.take()` — never unbounded result sets.\n- Prefer `.pluck(:col, :col)` over loading full AR objects.\n- Mask PII in output (truncate emails, addresses, payment details).\n- `.explain` before querying large tables without indexed conditions.\n- Emit structured output (JSON for complex, `.inspect` for simple) so results are parseable.\n\n## Gumroad-specific gotchas\n\n### External IDs, not primary keys\n\nAdmin URLs and public IDs use **external IDs** (`ExternalId` module — Base64 strings like `aBcDeFgHiJkLmNoPqRsTuQ==`), not integer PKs.\n\n```ruby\nPurchase.find_by_external_id(\"aBcDeFgHiJkLmNoPqRsTuQ==\")  # correct\nPurchase.find(\"aBcDeFgHiJkLmNoPqRsTuQ==\")                  # WRONG — treats it as a PK, silently returns the wrong record\n```\n\n### Model naming\n\n| Model | Note |\n|---|---|\n| `Link` | The product model (legacy name). `find_by(unique_permalink:)` for permalinks; `alive` scope for non-deleted. |\n| `Installment` | Subscriptions/recurring (not ActiveRecord's sense of \"installment\"). `where(link_id:, alive: true)`. |\n| `Comment` | Admin notes on records. `content` field, **not** `body`. |\n| `MerchantAccount` | Processor-specific — users can have multiple. `where(user_id:, charge_processor_id:)`. |\n| `Purchase` | `successful` scope for completed sales. `where(email:)`, `where(link_id:)`. |\n\n`User`, `Balance`, `Dispute`, `Follower`, `CustomDomain` behave as names suggest.\n\n### Sidekiq queues\n\n`critical` (12k limit — payouts, webhooks, receipts) → `default` (300k — general) → `long` (PDF stamping etc.) → `low` (expiry). Queue limits at `app/controllers/healthcheck_controller.rb:28`; worker ordering at `docker/web/sidekiq_worker.sh`.\n\n### DevTools (Gumroad-specific helpers)\n\nSee `lib/utilities/dev_tools.rb`:\n\n- `DevTools.reindex_all_for_user(user_id)` — reindex ES data\n- `DevTools.reimport_follower_events_for_user!(user)` — reimport follower analytics\n\nFor Gumroad-specific scopes and associations (`alive`, `successful`, `unpaid_balance_cents`, `payments`, `products`, Flipper checks, Sidekiq introspection), see [references/common-queries.md](references/common-queries.md).\n\n### Stale bastion host keys look like unhealthy instances\n\nEC2 recycles private IPs, so the **bastion's** `known_hosts` accumulates stale keys and refuses the onward hop with `REMOTE HOST IDENTIFICATION HAS CHANGED` / `Offending ECDSA key`. From outside this is easy to mistake for a hung instance, and it silently shrinks the usable pool each time instances are replaced.\n\n`prod_query.sh` catches them without touching the pin: a candidate is recorded as stale-keyed when a changed-key complaint names its address — including on a probe that looked healthy, since the forced command exits 0 after its own hop refused — and the run reports those addresses with the one-hop `ssh-keygen -R` command to run by hand.\n\n- **The pin is never removed automatically.** Every textbook trigger for a removal is something a pin exists to defend against: EC2 membership says the address is a pool member, not that the key it serves is the instance's, and a changed-key complaint is an alarm rather than proof of a recycle — deleting the entry and reconnecting pins whatever answered. Clearing is an explicit operator action, printed as a one-hop command.\n- **Only genuinely outdated keys are reported.** A plain timeout, a container that is still starting, or a network blip leaves the recorded key alone — only SSH naming that address counts. The onward hop usually just *warns* about a changed key and connects anyway (recycled IPs make that the steady state), so a warn-and-proceed failure stays on the patient-retry list too. Only an outright `Host key verification failed` refusal drops a candidate from the retry.\n- **A pool rejected entirely for stale keys says so**, with that same command, instead of the generic health-probe error.\n\n- **`LC_PAPER=127.0.0.1` means the bastion itself.** The forced command jumps to whatever `LC_PAPER` names, and omitting the variable — or the `-o SendEnv=LC_PAPER` that ships it — fails with `ssh: Could not resolve hostname`; instance IPs run the command on that instance. Anything aimed at the bastion's own files goes through `127.0.0.1`.\n- **The warning text is often noise.** A run can print the whole man-in-the-middle banner for a *previous* hop and still succeed. Judge by exit code and `MARK` output, not the banner. `rc 255` is the transport dropping mid-flight, so the outcome is unknown — do not read it as a failed query. Any *other* nonzero rc whose only output is the banner means the query failed: look for `Query process killed by SIGKILL` or the query's own output.\n\n### \"No instance passed the health probe\" usually means slow, not down\n\nThe candidate probe is deliberately impatient (20s) so one hung host cannot eat the caller's budget, but that also rejects hosts which are merely slow under load. When *every* candidate fails, a fleet-wide outage is the less likely reading.\n\n`prod_query.sh` now retries the non-stale-key rejections with a patient probe before giving up, and says so (`answered on the patient retry (slow, not unhealthy)`). Only the all-rejected path pays for this.\n\nBoth passes draw down one shared **90s selection budget** (`PROD_SELECT_BUDGET`), so picking a host can never consume the caller's whole ~120s Bash-tool window before the query starts — a large pool shortens each probe rather than adding to the total. A third of the budget is held back for the patient pass, so a pool of slow hosts cannot drain everything in the fast pass and starve the retry. Running out gives a distinct message that says the pool may just be slow, instead of the misleading \"no instance passed the health probe\". Raise the budget when you have more wall clock than the Bash tool allows:\n\n```bash\nPROD_SELECT_BUDGET=240 timeout 560 bash .agents/skills/gumroad-prod-console/scripts/prod_query.sh q.rb\n```\n\nIf you still get the hard error, force a host rather than assuming production is down:\n\n```bash\nPROD_INSTANCE_IP=10.1.34.180 bash .agents/skills/gumroad-prod-console/scripts/prod_query.sh q.rb\n```\n\nThis matters for **watchers**, not just interactive use: a cron that treats an unreadable console as \"nothing to report\" goes silent indefinitely and looks healthy. On 2026-07-29 all 8 candidates were rejected while a forced host answered the same query in 13s.\n\n### Grepping only for `^MARK` can hide a total failure\n\n`... | grep \"^MARK\"` on a run that never reached Rails returns empty with **exit 0**, which reads as \"query worked, no rows\". Capture to files and echo the exit code:\n\n```bash\ntimeout 560 prod_query.sh /tmp/q.rb > /tmp/q.out 2>/tmp/q.err; echo \"exit=$?\"\ngrep -a \"^MARK\" /tmp/q.out\n```\n\nExit `124` = outer timeout (query too heavy). `255` = SSH itself failed: either the hop dropped before Rails started, or the transport dropped mid-flight with the spool state unknown — treat the query's outcome as unknown rather than failed.\n\n### `association.unscoped` reads the whole table\n\n`Link` has no `default_scope`, so `user.links` already includes deleted rows — there is nothing to reach for `.unscoped` for. On an association it drops the `user_id` condition instead, leaving `SELECT * FROM links` (millions of rows). Iterating that gets the query process OOM-killed: the run exits nonzero, prints `Query process killed by SIGKILL`, and the stale host-key banner above it is **not** the cause. Use `user.links` (or `Link.where(user_id: user.id)`).\n\n### Keep Stripe API calls out of loops\n\nThe DB is fast; `Stripe::Transfer.list` / `Charge.retrieve` per record is what blows the timeout — even ~5 accounts with one Stripe call each can hit exit `124`. Use raw SQL via `ActiveRecord::Base.connection.select_all` for population questions, and fetch at most 1–2 Stripe objects per run when you need live processor state.\n\n## Requirements\n\n- An AWS profile with `ec2:DescribeInstances` on the prod account. The script defaults to the profile `gumroad-prod` (created by `scripts/setup.sh`). Override by exporting `AWS_PROFILE` or setting `PROD_AWS_PROFILE` in your config file.\n- SSH access to your production bastion (defaults to `bastion-production.gumroad.net`)\n\n### Gumroad team one-time setup\n\nIf you previously ran this script via `GUMROAD_DEPLOYMENT_DIR` and `.env.aws`, run the helper from the repo root to migrate those creds into an AWS CLI profile:\n\n```bash\n.claude/skills/gumroad-prod-console/scripts/setup.sh\n# or pass an explicit path:\n.claude/skills/gumroad-prod-console/scripts/setup.sh ~/path/to/.env.aws\n```\n\nThe helper will also offer to append `export AWS_PROFILE=gumroad-prod` to your shell profile — say yes and reload the shell. After this, `gumroad-private` is no longer required to run the skill.\n\n## Configuration (self-hosters)\n\nDefaults target Gumroad's prod infra. If you're running your own Gumroad fork, override by creating `~/.config/gumroad-prod-console.env`:\n\n```bash\nPROD_BASTION=bastion.mycompany.com\nPROD_SECURITY_GROUP=my-web-sg\nPROD_CONTAINER_FILTER=app-*\nPROD_DB_HOST_VAR=MY_READ_REPLICA_HOST\nPROD_AWS_PROFILE=my-aws-profile\n```\n\nOr export the same variables before invoking the script.\n\n<!-- File: .agents/skills/review-pr/references/review-guidance.md (Tokens: ~300 | Category: anthropic-skill) -->\n# References Skill\n\nPath: `.agents/skills/review-pr/references/review-guidance.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.agents/skills/review-pr/references/review-guidance.md)\n\n<!-- File: .agents/skills/review-pr/SKILL.md (Tokens: ~1192 | Category: anthropic-skill) -->\n---\nname: review-pr\ndescription: >\n  Review GitHub pull requests for the Gumroad codebase against project guidelines, code quality,\n  and correctness. Use when the user wants to review a PR, check new comments on a PR, provide\n  feedback on code changes, or asks about PR quality. Triggers on: \"review PR\", \"review this PR\",\n  \"check PR #NNN\", any GitHub PR URL (e.g. https://github.com/antiwork/gumroad/pull/NNNN),\n  \"what's new on the PR\", \"check comments\", \"review the diff\", \"give feedback on this PR\",\n  or requests to evaluate code changes in a pull request.\n---\n\n# PR Review\n\nReview pull requests for code quality, correctness, and CONTRIBUTING.md compliance.\n\n**Scope boundary**: This skill evaluates _how the code is written_. For evaluating _whether the PR solves the right problem_, use the `issue-detective` skill instead.\n\n## Workflow\n\n### 1. Fetch PR Context\n\n```bash\ngh pr view <number> --repo antiwork/gumroad\ngh pr diff <number> --repo antiwork/gumroad\ngh pr view <number> --repo antiwork/gumroad --comments\ngh api repos/antiwork/gumroad/pulls/<number>/comments\n```\n\nAlso read `CONTRIBUTING.md` from the repo root — it's the source of truth for all guideline checks.\n\nNote PR status (draft, closed, merged). Skip review if merged or closed unless user insists.\n\n### 2. Understand the Change\n\nFrom the diff and description, determine:\n\n- What changed and why (read the linked issue if referenced)\n- Bug fix, feature, refactor, or chore\n- Which layers are affected (frontend, backend, both, tests only)\n- Size and complexity\n\nRead key modified files in full when the diff alone is insufficient to understand context.\n\n### 3. Review\n\nRun these review passes on the diff:\n\n**Pass 1 — Bugs and Logic Errors**\nWrong conditionals, missing edge cases, race conditions, nil/null handling, off-by-one errors, security vulnerabilities (injection, XSS, CSRF). Focus on code paths introduced or modified by the PR.\n\n**Pass 2 — CONTRIBUTING.md Compliance**\nCheck the diff against every applicable rule in CONTRIBUTING.md (code standards, naming conventions, testing standards, Sidekiq patterns, PR structure, etc.). The file is the single source of truth — do not maintain a separate checklist.\n\n**Pass 3 — Code Clarity**\nEvaluate readability and maintainability of new/modified code. See [references/review-guidance.md](references/review-guidance.md) for what to flag vs what to leave alone. The goal is clear, explicit code — not clever or compact code.\n\n**Pass 4 — PR Structure**\nAI disclosure, description quality (explains _why_), before/after media, test results, appropriate size — all per CONTRIBUTING.md.\n\n**⛔ Media evidence is a BLOCKING check (CONTRIBUTING.md line 17 — a hard `Must`).** If the PR touches product/UI (any view, component, CSS, layout, mobile behavior, user-visible copy) and has **no before/after video or screenshots**, raise it as a **critical** finding at **confidence 95+** and set the Verdict to request-changes. Do NOT wave through a mobile/CSS/layout PR just because the prose description is clear — \"the code looks right\" is not a substitute for visual proof. A missing video on a non-visual change (no walkthrough) is at least an **important** finding. This does not apply to PRs that only touch documentation or agent skill files — the diff itself is the reviewable artifact, so do not raise a missing-media finding for docs- or skill-only PRs.\n\n### 4. Score and Filter\n\nAssign each finding a confidence score (0–100): how likely is this a genuine problem?\n\n**Keep** findings >= 80. **Drop** findings below 80.\n\nSee [references/review-guidance.md](references/review-guidance.md) for noise filtering rules and severity levels.\n\n### 5. Report\n\n```\n## Summary\n\n[1-2 sentences: what the PR does and overall assessment]\n\n## Issues\n\n### [critical|important|suggestion] Title\n**File:** `path/to/file.rb:NN`\n**Confidence:** NN/100\n\n[Concise explanation and fix suggestion if straightforward.]\n\n...\n\n## Checklist\n\n[CONTRIBUTING.md items that are missing — e.g., no AI disclosure, no before/after, missing tests]\n\n## Verdict\n\n[approve / request-changes / comment-only — with brief justification]\n```\n\nWrite the review to `gh-pr-review.md` in the repo root. Do NOT stage or commit. Do NOT post to GitHub.\n\n### 6. Iterate\n\nWhen the user asks to re-check a PR (new comments, updated diff):\n\n```bash\ngh pr view <number> --repo antiwork/gumroad --comments\ngh api repos/antiwork/gumroad/pulls/<number>/comments\ngh pr diff <number> --repo antiwork/gumroad\n```\n\nFocus on what changed since the last review. Don't repeat prior findings unless still unaddressed.\n\n## Important\n\n- Use `gh` read-only only. Never approve, comment on, or request changes via CLI.\n- Review what the PR _introduces_ — not pre-existing code.\n- Be direct and concise. No filler praise.\n- Prioritize substantive issues over cosmetic ones.\n- When unsure, state uncertainty rather than false confidence.\n- For large PRs (1k+ lines), note the PR should be broken up.\n\n<!-- File: .agents/skills/test-confidence/SKILL.md (Tokens: ~714 | Category: anthropic-skill) -->\n---\nname: test-confidence\ndescription: AI-driven test execution. Opus decides what to run and how confident to be, based on your diff.\nargument-hint: \"--full to run to 100% | --strict to halt on pre-existing failures\"\nallowed-tools: Bash(git *), Bash(bundle exec rspec *), Bash(cat *), Bash(find *), Bash(wc *), Bash(head *), Bash(tail *), Bash(grep *), Bash(bin/test-confidence *)\n---\n\n# Test Confidence\n\nRun `bin/test-confidence` to have Opus 4.7 analyze your diff, decide the risk level, plan which tests to run and in what order, and set confidence milestones. The AI decides the shape of the curve based on this specific diff.\n\n## Usage\n\n```bash\nbin/test-confidence            # Run to 99%, stop. Skips past pre-existing failures.\nbin/test-confidence --full     # Run to 100%\nbin/test-confidence --strict   # Halt on any failure, including pre-existing\n```\n\n`ANTHROPIC_API_KEY` is auto-sourced from `.env` if not exported.\n\nIf `$ARGUMENTS` is provided, pass it through: `bin/test-confidence $ARGUMENTS`\n\n## How it works\n\n1. Finds changed files (branch diff vs main for PR branches, local diff on main)\n2. Hashes the diff and checks `tmp/test-confidence/` for a cached plan; reuses if found\n3. Otherwise sends the diff + spec tree + touched directories to Opus 4.7 in one call\n4. Opus returns a plan: risk level, ordered test list, confidence milestones\n5. Script executes the plan, showing yellow progress bar toward 99%\n6. At 99%, bar turns green. Safe to commit.\n7. With `--full`, continues running remaining tests toward 100%\n\n## Pre-existing failure detection\n\nWhen a spec fails, the script applies a two-step check:\n\n1. **Path heuristic** — does the failing spec file or its source counterpart (e.g., `app/models/user.rb` for `spec/models/user_spec.rb`) appear in the diff?\n   - **In the diff** → real regression. Halt immediately.\n   - **Not in the diff** → suspect pre-existing. Verify on merge-base.\n2. **Merge-base verify** — re-run the failing rspec examples in a temporary worktree at the branch's merge-base with `main`.\n   - **Still fails on merge-base** → confirmed pre-existing. Continue.\n   - **Passes on merge-base** → cross-file regression caught. Halt.\n\nThe verify step is skipped (heuristic verdict trusted) when the diff includes a `db/migrate/*.rb` (DB schema drift) or `Gemfile.lock` (bundler drift), or when no merge-base is available. `--strict` skips both checks and halts on every failure.\n\nCost profile: zero overhead in the common no-failure case; verify only runs on heuristic-flagged \"pre-existing\" failures. Catches the dangerous direction (silent regression skipped) while keeping false-alarm investigation costs bounded.\n\nThe key insight: Opus decides ad hoc how many tests are needed for each confidence level. A comment-only change might need 2 tests for 99%. A payment model refactor might need 100.\n\n## When to use\n\nRun this before every commit. It replaces manually picking which specs to run.\n\n$ARGUMENTS\n\n<!-- File: .claude/skills/commit (Tokens: ~7 | Category: claude-rule) -->\n../../.agents/skills/commit\n\n<!-- File: .claude/skills/create-issue (Tokens: ~7 | Category: claude-rule) -->\n../../.agents/skills/create-issue\n\n<!-- File: .claude/skills/email-blast (Tokens: ~8 | Category: claude-rule) -->\n../../.agents/skills/email-blast\n\n<!-- File: .claude/skills/gumroad-prod-console (Tokens: ~11 | Category: claude-rule) -->\n../../.agents/skills/gumroad-prod-console\n\n<!-- File: .claude/skills/review-pr (Tokens: ~300 | Category: claude-rule) -->\n# Claude Directive: review-pr\n\nPath: `.claude/skills/review-pr`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/review-pr)\n\n<!-- File: .claude/skills/test-confidence (Tokens: ~300 | Category: claude-rule) -->\n# Claude Directive: test-confidence\n\nPath: `.claude/skills/test-confidence`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/antiwork/gumroad/HEAD/.claude/skills/test-confidence)\n\n<!-- File: .github/copilot-instructions.md (Tokens: ~8 | Category: copilot-instructions) -->\nSee CONTRIBUTING.md for development guidelines\n</yakaai_skills>"}