Agent Instructions for warmlyyours/heatwave

Single source of truth for agent context across Claude Code, Aider, Codex
CLI, Cursor, kimi-code, and any tool honoring the AGENTS.md convention
(https://agents.md/). CLAUDE.md is a symlink to this file. The hard
rules below load in every tool; intent-triggered conventions live as
skills under .agents/skills/ (reached by Claude Code via the
.claude/skills symlink; first-party and vendored share one tree).


Toolchain — non-negotiable

Heatwave pins Ruby, Node, Python, and uv via mise (.mise.toml).

Always prefix Ruby/Node/Yarn commands with mise exec -- — bare
commands pick up system versions and fail:

mise exec -- bin/rails runner script.rb
mise exec -- bundle exec rails test
mise exec -- yarn build

Caveat — mise exec -- resolves the version from the cwd, and agent
Bash calls reset cwd between invocations.
A call that lands outside the
repo silently gets the global Ruby (3.4.7) instead of the pinned
4.0.6 — no error, just a wrong interpreter. mise where ruby is
cwd-sensitive the same way. From an agent, cd to the absolute project
path, export once, then call bundle bare:

cd /path/to/heatwave_master && export PATH="$(mise where ruby)/bin:$PATH"
bundle exec rails test

Never hardcode the version path — $(mise where ruby) only. Superseded
installs linger under ~/.local/share/mise/installs/ruby/ (4.0.4 and
4.0.5 are both still there), so a hardcoded path resolves successfully
to a stale interpreter rather than failing loudly.

  • Yarn only, never npm.
  • Minitest under test/, never RSpec.
  • PostgreSQL with db/structure.sql as schema source
    (schema_format = :sql); db/schema.rb does not exist by design.

PR definition of done

Before creating or updating a pull request, and before handing a pushed change
back to the user:

  1. Behavior changes use the testing skill. Run the affected tests with
    COVERAGE=1. Exercise every added conditional, guard clause, early return,
    rescue, and fallback—or delete the branch if it is not needed. Target 100%
    of added executable Ruby lines; CI hard-fails below 90% across app/,
    lib/, and Ruby scripts under script/.
  2. Run bin/pr-ready in proportion to risk. Its full mode runs
    deterministic checks, the full Minitest suite, the exact changed-line
    coverage gate, then the local semantic-review panel (bin/setup --reviewers; none skips models). Use --quick when no Ruby behavior
    changed. For documentation-only changes and genuinely trivial typo/copy
    edits, a developer may skip bin/pr-ready and rely on focused validation,
    the localized Git hooks, and GitHub's required checks. Report what was
    intentionally skipped; never bypass a check merely because it is failing.
  3. Use Linux parity when the environment is part of the change. For CI,
    Docker, test-harness, or Git/filesystem-sensitive changes, also run
    CI_DOCKER_TESTS=1 mise exec -- bin/ci --tests-only.
  4. Own the current PR head through checks. After pushing, run
    gh pr checks <number> --watch. Do not declare the work ready while the
    current SHA is pending or red. Inspect the exact failed log; rerun only with
    evidence of a transient failure.
  5. Report unavailable verification. If a required command cannot run
    locally, say exactly why and do not describe the change as PR-ready.

Hard-block commands — never run without explicit user permission

Command Reason
git commit User decides what gets committed
db:migrate against prod, db:rollback, db:migrate:redo (any env) Schema/data risk. Dev db:migrate is fine without asking (it no longer rewrites db/structure.sql; see Migration safety). Prod migrations go through bin/deploy; rollback/redo revert data, so always ask.
rails server Ask first
rails console Breaks the environment in this repo
bin/deploy Always ask
gh pr merge, branch→master push Never on your own initiative — the user decides what lands. An explicit "merge" / "PR" command is fine to execute (see Branching).
git push --force to master Always warn
docker compose down docker-compose.yml:15 pins name: heatwave, so this tears down pg / valkey / mailpit for every worktree on the host, not just yours. Use docker compose rm -sf <service>.
rm -rf against repo paths Reversibility risk
--no-verify / --no-gpg-sign on commits Skips hooks for a reason

The table above is the canonical list.

Bulk operations — anything affecting >1000 records requires two confirmations

Mass operations (DB UPDATEs/DELETEs, Sidekiq enqueues, external-API
loops, mass file writes) that could exceed 1000 rows/jobs must:

  1. Count first, run second. Compute and surface the actual count
    from the actual scope (show the SQL/Ruby and filters) before drafting
    any loop. No estimating.
  2. Two explicit confirmations, separated by another clarifying
    question (scope, window, quota, blast radius). Not one bundled
    "are you sure".
  3. Default to NARROW scope. "All" / "100%" coverage defaults to a
    recent window (last 4 months, or smallest that meets the goal);
    the user widens explicitly.
  4. Surface external-system cost. For rate-limited/billed APIs
    (ShipEngine, Stripe, SP-API, …), state per-call cost/quota
    implications in the confirmation.
  5. No undo, no run. Establish the cleanup story BEFORE enqueueing
    (e.g. Sidekiq jobs can't be selectively pruned from a shared
    :default queue).

Origin: a 315k-job enqueue against ShipmentTrackingRegistrationWorker
(2026-05-29) scoped to "every parcel shipment ever" instead of the
carrier's retention window; cleanup required killing Sidekiq. Never
again.

External systems — exhaust self-service before asking a human

When you need a change in a third-party system (Wayfair, Amazon, Google,
Cloudflare, a carrier, a supplier portal), work down this ladder and
stop at the first rung that can do the job. Research the rung above
before you settle for the one below — do not start at the bottom.

  1. API. Ours or theirs. Scriptable, reviewable, repeatable.
  2. Self-service portal, driven in the browser. Most portals expose far
    more than their docs suggest — per-SKU toggles, bulk Excel export/import,
    settings pages that never appear in the nav. Look for an export/import
    round-trip before concluding it is manual.
  3. Support ticket. Slow, and someone else's queue.
  4. Human intervention. A person on our side, or an account manager.

Skipping to rung 3 wastes days and creates churn you then have to unwind.
Origin: on 2026-07-28 five Wayfair tickets were filed to add and then remove
Canada shipping restrictions; Wayfair's reply pointed at
Products → Product Compliance → International Shipping, a self-service
per-SKU control with a bulk Excel export. All five tickets were unnecessary,
and because support works its queue in filing order the add landed before the
cancellation, briefly restricting a live product.

Applies to reads too — prefer querying the system of record over trusting a
ticket reply that says something was done.

Code deletion safety

Before deleting a concern, method, class, or constant, search references
— dynamic dispatch hides callers:

rg "method_name" app/ lib/ test/

Also check send(:foo) / public_send and metaprogramming. List any
hits to the user before proceeding.

For associations, grep is especially unreliable
(joins/includes/eager_load/preload, through-associations). Mark
them deprecated: true (Rails 8.1) and let traffic prove them dead:

has_many :legacy_widgets, deprecated: true   # boolean only

Reporting is global (config.active_record.deprecated_associations_options):
:raise in dev/test, :notify in staging/production where
config/initializers/350_deprecated_association_reporter.rb forwards hits
to AppSignal. Delete after one release cycle at zero. Full workflow:
god-object-decomposition.

No compatibility shims

When a major dependency bump rewrites the API surface (cropperjs 1→2's
cropper.zoom()<cropper-image>.$zoom(), etc.), port every call
site to the new API. Never ship a v1-compat facade or adapter to avoid
touching callers: it leaves two parallel APIs to reason about, sticks as
load-bearing infra rather than the migration aid it claims to be, and
hides the bugs real migration should surface. The diff shape is N
call-site edits, not 1 shim + N thinner edits. Too big for one PR? Split
per call site — but inside any PR the calls target the new API directly.

Visual design — read the DESIGN contract before writing UI

Two surfaces, two contracts, both at repo root in the
design.md alpha format
(YAML token front matter + prose rules):

  • @DESIGN.www.md — public site (warmlyyours.com):
    client/stylesheets/www/, client/js/www/, app/views/www/.
  • @DESIGN.crm.md — internal CRM: client/stylesheets/crm/,
    client/js/crm/, app/views/crm/.

Read the contract for the surface you're touching, in full, before
generating any view, ViewComponent, or SCSS.
Don't invent colors, fonts,
spacing, radii, or shadows outside it — the token tables are the whole
sanctioned palette, and the "Do's and Don'ts" section is binding, not
advisory. When SCSS and the contract disagree, the SCSS is drift: fix the
SCSS, or raise it, but don't copy the drift forward.

Both files carry States, Motion, Iconography, and Responsive
sections — check them for hover/focus/disabled treatment, the
prefers-reduced-motion rule, the Font Awesome Sharp-only constraint, and
breakpoint/touch-target behavior before hand-rolling any of it.

Editing a contract? script/validate_design.sh lints every DESIGN.*.md
against the upstream spec. Keep it at zero errors.

Migration safety

db/structure.sql is regenerated on master at deploy time — never
locally.
dump_schema_after_migration is false everywhere, so dev
db:migrate doesn't touch schema files. A migration PR ships ONLY files
under db/migrate/ / db/versions_migrate/ — never commit
db/structure.sql or db/versions_structure.sql changes. Consumers load
the committed baseline and apply pending migrations on top (db:prepare;
test/test_helper.rb's setup_test_database!;
bin/setup --reset-test-db), so a lagging baseline is safe. The one
sanctioned writer is bin/regen-structure (scratch DBs: schema:load
migrateschema:dump), run by bin/deploy production on master.
Never dump from a long-lived dev DB — prod-snapshot restores and branch
DBs pollute the file. Clear any old
git update-index --skip-worktree db/structure.sql.

Never edit an already-applied migration — write a new one.
setup_test_database! migrates only versions missing from
schema_migrations, so once your test DB has run a migration, later edits
to that file never execute again locally or in CI. The edited version
ships validated by nothing.

In a worktree, branch the DB before risky migrations. Dev
db:migrate needs no permission (see the hard-block table), but
unmerged, irreversible, or column-dropping migrations must land on a
copy-on-write clone — not the shared heatwave golden master every other
worktree clones from:

bin/db branch create   # clone + write DATABASE_NAME to .env.db.local

Pin DATABASE_NAME on every worktree db:migrate. .env.db.local
is loaded by direnv, and agent / non-interactive shells never run direnv,
so DATABASE_NAME arrives unset and config/database.yml:46 falls back
to 'heatwave'. A bare db:migrate from a worktree therefore migrates
the SHARED golden master, poisoning every other worktree's
bin/db branch reset. Nothing guards this — pin it yourself:

# ❌ from a worktree: silently migrates the shared `heatwave` DB
mise exec -- bin/rails db:migrate

# ✅ pin the clone bin/db wrote to .env.db.local
DATABASE_NAME=heatwave_my_branch mise exec -- bin/rails db:migrate

Use the Rails generator for migration filenames — round-number
timestamps collide silently after merges
(ActiveRecord::DuplicateMigrationNameError):

mise exec -- bin/rails generate migration AddColumnToTable
# by hand only if you must: date +%Y%m%d%H%M%S

Migration version must match Rails 8.1
ActiveRecord::Migration[8.1] (the generator stamps it).

Guard hardcoded record IDs in data migrations — prod has records
dev/CI don't and vice versa. find_by, not find; verify both sides of
associations:

# ❌ crashes when record doesn't exist
Catalog.find(76).update!(excluded_carriers: ['SpeedeeDelivery'])

# ✅ safe
catalog = Catalog.find_by(id: 76)
catalog&.update!(excluded_carriers: ['SpeedeeDelivery'])

# ✅ verify both sides for associations
next unless taggable_type.constantize.exists?(id: record_id)
Tagging.create!(tag: tag, taggable_id: record_id, taggable_type: taggable_type)

Wrap Array() around JSONB array accessors that may be nil on existing
rows: Array(catalog.excluded_carriers).reject(&:blank?).

Data migrations use local model shims, not app models. Referencing an
app constant couples the migration to a model that may later be renamed
or deleted — a behind environment then crashes replaying the chain, which
breaks bin/deploy. Nest an AR shim inside the migration class (14
migrations already do; canonical example
db/migrate/20260626072029_cleanup_orphan_amazon_variations.rb):

class CleanupOrphanAmazonVariations < ActiveRecord::Migration[8.1]
  # ❌ AmazonVariation the app model was absorbed into VariantGroup and deleted
  # ✅ the shim keeps this migration replayable forever
  class AmazonVariation < ActiveRecord::Base
    self.table_name = 'amazon_variations'
  end
end

Declare only the associations the migration actually needs — and note
they must reference the shim (class_name: 'MigrationName::Foo'), not
the app model.

New datetime columns are timestamptz.
config/initializers/071_active_record_timestamptz.rb sets
PostgreSQLAdapter.datetime_type = :timestamptz, so t.datetime,
t.timestamps, and add_column …, :datetime emit
timestamp with time zone (why,
rails/rails#41084). The ~40
legacy tables stay timestamp without time zone — fine on disk since
Rails stores UTC; don't "fix" them wholesale. Use t.timestamp /
t.timestamptz to override a single column.

The flip has a read-side trap the initializer also guards: legacy
timestamp columns report :timestamp, which is not in the default
time_zone_aware_types, so they'd read as bare UTC Time instead of
TimeWithZone and views would render UTC. The initializer appends
:timestamp to time_zone_aware_types — don't remove that line, and add
any new datetime type symbol to that list.

Converting an existing column is a full-table rewrite — a naive
ALTER COLUMN … TYPE timestamptz reinterprets values in the session
zone and corrupts data. Pin the source zone (our naive values are UTC)
and route large tables through online_migrations (ACCESS EXCLUSIVE
lock + rewrite):

execute <<~SQL
  ALTER TABLE foos
    ALTER COLUMN created_at TYPE timestamptz USING (created_at AT TIME ZONE 'UTC'),
    ALTER COLUMN updated_at TYPE timestamptz USING (updated_at AT TIME ZONE 'UTC')
SQL

Zeitwerk namespaces

Nesting a class under a namespace matching an ActiveRecord model
(class ModelName::SomethingElse in model_name/) is fine on Rails
8.1 / Zeitwerk 2.6 — dozens run in production (Coupon::*, Quote::*,
Catalog::*, Customer::*) and bin/rails zeitwerk:check passes.
Prefer namespacing.

The one real failure mode: a gem resolving the bare top-level
constant during eager-load gets Zeitwerk's implicit-namespace module
instead of the model class. Known case: the noticed gem does
Class.new(const_get(:Notification)), so any notification/ directory
breaks eager-load with TypeError: superclass must be an instance of Class. Only Notification is affected:

# ❌ only because `noticed` grabs the bare ::Notification constant
class Notification::ShippingTrackingHandler ...
# ✅ flatten just that one class
class NotificationShippingTrackingHandler ...

# ✅ everything else nests fine — prefer it
class Opportunity::Copier ...
class RoomConfiguration::CalculateQuote ...

The guard is bin/rails zeitwerk:check, wired into bin/ci. If it
fails for a model-named namespace, flatten that one class — never
wholesale. (History: Party/CallRecord crashed on Rails 7 → an over-broad
"never nest" rule and a flattening sweep, now being reverted.)

Git commit technique

Multi-line cat <<'EOF' heredoc commit messages fail silently in
sandboxed shells
. Use a temp file via the Write tool, then
git commit -F:

# Write /tmp/commit_msg.txt with the Write tool, then:
git commit -F /tmp/commit_msg.txt
rm /tmp/commit_msg.txt

Before amending, verify ownership and unpushed state:

git log -1 --format='%an %ae'   # confirm you authored it
git status                       # confirm "ahead" — not yet pushed

Corrupted config/credentials.yml.enc

Symptom: the file roughly doubles (~60KB → ~126KB) and gains newlines,
and Rails won't boot —
ActiveSupport::MessageEncryptor::InvalidMessage: missing separator.
The cause is unproven: .gitattributes attaches only a
diff=rails_credentials textconv driver to that path, no clean/smudge
filter. Recover by streaming the blob straight out of the object store:

# ✅ writes the blob verbatim
git cat-file blob HEAD:config/credentials.yml.enc > config/credentials.yml.enc

# ❌ re-runs the checkout path that produced the corrupt file
git checkout HEAD -- config/credentials.yml.enc

Branching

  • One PR per topic — follow-ups stack onto the open PR. Branch off
    master only for genuinely independent work with its own merge
    cadence. When a PR is open and the user asks for "also do X", commit
    onto that branch and widen the PR description; when the change touches
    a file that has unmerged work in another PR, it belongs there. A
    logically distinct file or area is not a reason to split — ask before
    git checkout -b.
  • Never merge to master on your own initiative — no gh pr merge,
    no branch→master push, not even to unblock the user (have them
    git checkout <branch> instead; add db:migrate if it carries
    migrations). But do execute an explicit "PR" / "merge" instruction
    without over-confirming or citing this rule back. Humans may
    fast-forward small, low-risk changes themselves; force-push to
    master stays prohibited either way.
  • gh pr merge --squash --delete-branch from a linked worktree
    prints fatal: 'master' is already used by worktree at …. The
    squash-merge and the remote-branch delete both succeeded
    — only gh's
    local git checkout master cleanup failed, because the main worktree
    holds master. Don't re-run it; confirm with
    gh pr view <n> --json stateMERGED. The orphaned local branch is
    harmless.

Where things live

AGENTS.md              This file. Hard rules + pointers to skills.
CLAUDE.md              Symlink → AGENTS.md (Claude Code entry point).
.agents/skills/<name>/ Skill packs (SKILL.md + references/), agentskills.io standard.
.claude/skills         Symlink → ../.agents/skills (Claude Code reads here).
skills-lock.json       Lockfile for vendored third-party skills (hash-pinned).
doc/                   Long-form architecture/analysis docs.
doc/tasks/             Dated task plans (YYYYMMDDHHMM_NAME.md).
DESIGN.www.md          Design contract for the public site (design.md spec).
DESIGN.crm.md          Design contract for the internal CRM (design.md spec).
db/structure.sql       Schema source of truth.
.mise.toml             Toolchain pins.

Before writing code for a feature, check whether a relevant skill exists
— skills carry hard-won conventions (strong params, Turbo patterns,
state machine validations) to follow rather than rediscover.

One documentation system

One place per kind of knowledge, all plain markdown:

  • Hard, always-on rules → this file.
  • Conventions + how-to per working area → a skill under
    .agents/skills/. If a convention isn't in a skill yet, add it there —
    don't start a parallel doc or standards/ tree.
  • Long-form architecture, runbooks, ADRsdoc/ (published to the
    Starlight portal).

Cross-session memory bank

Claude Code keeps a per-developer memory bank at
~/.claude/projects/<this-project>/memory/ (a MEMORY.md index linking
to one *.md per fact), auto-loaded in Claude Code and not committed.
kimi-code mounts it read-only via .kimi-code/local.toml's
[workspace] additional_dir. When in reach, read MEMORY.md at session
start and open linked files when a task matches. Entries are
point-in-time — verify any file/flag/function still exists before acting
on it.

Adding a skill

  1. Create .agents/skills/<name>/SKILL.md with agentskills.io
    frontmatter (name, description, optional license,
    compatibility, metadata, allowed-tools).
  2. List it in the skill index below.

Claude Code discovers skills via the .claude/skills symlink; Zed and
kimi-code read .agents/skills/ natively; vendored skills (tracked in
skills-lock.json) share the tree. Run script/validate_skills.sh to
check the spec (frontmatter, name matches directory, description
1024 chars) and the budget (SKILL.md ≤ 500 lines — past that, move
detail into references/). Length warnings are advisory.

Skill index

Each skill is a directory with SKILL.md under .agents/skills/<name>/.
Read the SKILL.md for the area you're touching.

Area Skill
Rails core ruby-rails, modern-ruby-idioms, rails-ai-playbook, controllers, migrations, service-architecture, data-value-objects, god-object-decomposition
Models state-machines, advisory-locks, ltree-hierarchy, embeddings, data-model-manifest, postgres-cli, query-objects
Hotwire / UI view-components, presenters, forms, stimulus, turbo-streams, carousels, ui-conventions, no-inline-view-scripts, server-to-client-data, tag-helpers, render-partials, localization, webpack-bundling
Hotwire content packs hwc-forms-validation, hwc-media-content, hwc-navigation-content, hwc-realtime-streaming, hwc-stimulus-fundamentals, hwc-ux-feedback
CRM crm-pages
Marketplaces / retailers retailer-onboarding, wayfair-marketplace, amazon-marketplace, costco-marketplace, canadian-tire-store-refresh, commercehub-inventory-feed
Shipping / fulfillment shipengine-ltl
Auth / OAuth oauth-clients
Background work background-jobs, rails-event-store, webhooks, mcp-servers, mailers
Content blog-content, svgmaker, publications
Quality code-quality-audit, rails-audit, dhh, coderabbit-cli, multi-llm-review, testing, system-tests, security, tracking-consent
LLM ops ai-model-upgrade — model upgrades, pricing/fallback sync, and validation
Docs documentation-conventions, yard-model-surface-playbook
Edge / SEO / perf cloudflare-redirects, cloudflare-cache-purge, lighthouse-cli, pagespeed-insights, screaming-frog-cli, keywordspeopleuse
Cloudflare Workers wrangler, workers-best-practices (explicit-only; vendored from cloudflare/skills)
Browser debugging (Chrome DevTools for Agents) chrome-devtools, chrome-devtools-cli, a11y-debugging, debug-optimize-lcp, memory-leak-debugging, troubleshooting
External CLIs / APIs basecamp-cli, vultr-cli, context7, clarity, oxylabs, google-search-console, google-analytics, google-ads, openai-ads, pinterest-ads, facebook-ads, microsoft-ads, datadive, stripe-cli
Ops appsignal, server-health-check, project-reference, weekly-summary-rb-style
Docker / deploy docker-image-slimming, kamal-deploy, pgbouncer, postgres-replication
Infra / IaC (Terraform / OpenTofu) terraform-skill, terraform-style-guide
Coding posture Ponytail (DietrichGebert/ponytail) — default intensity full. bin/setup installs the official plugin for Claude, Codex, and Grok, and links the skills into ~/.kimi-code/skills for Kimi. /ponytail, /ponytail-review, /ponytail-audit.

Rules index

Hard rules are inlined above. Former .cursor/rules/*.mdc files are now
skills:

Topic Skill
Documentation conventions (YARD) .agents/skills/documentation-conventions/
Cloudflare bulk redirects .agents/skills/cloudflare-redirects/
Cloudflare cache purge .agents/skills/cloudflare-cache-purge/
CodeRabbit CLI workflow .agents/skills/coderabbit-cli/

New rules/conventions go in a skill — do not reintroduce .cursor/rules/
or per-tool config files.

Custom agents

The RuboCop auto-fix workflow lives in the
code-quality-audit skill,
under "Auto-fixing RuboCop violations on touched files."

In-flight plans

doc/tasks/ is the canonical home for staged work. Recent task docs:

  • doc/tasks/202608131451_YARD_LINT_TODO_BURNDOWN.mdactive ledger for the
    .yard-lint-todo.yml burn-down: which validator sections are cleared, what
    remains (UndocumentedMethodArguments / UndocumentedObjects), the enforced
    docblock conventions, and swarm incidents to avoid (never git-restore the todo).
  • doc/tasks/202608101200_AMAZON_LISTING_ISSUES_REMAINING.mdactive ledger
    for everything still open on Amazon listing issues (2026-08-10). Carries the
    two mechanisms that make the rest tractable: Amazon's PATCH is
    selector-scoped, so removing a stale locale needs op: delete WITH the
    selector in the value; and enforcement: null means a code is cosmetic
    (90225) while ATTRIBUTE_SUPPRESSED means the listing is not sellable
    (100907). Read it before touching Amazon listing data.
  • doc/tasks/202608041649_CONVERSION_VALUE_CORRECTION.mdactive ledger
    for the ad-conversion value fix (PR #1878, #1935). Code shipped,
    generate_lead went primary 2026-08-05, and the values now live in Setting
    (scope ad_conversion_values) editable at /crm/settings/ad-conversion-values
    ad_conversion_lead_value 200, ad_conversion_quote_value 100,
    ad_conversion_opportunity_rate 0.2 (the last read by all five offline ad
    reporters, which used to each carry their own copy). Setting is the single
    source of truth for every platform: Google Ads' always_use_default_value
    override was lifted 2026-08-08, so a change there moves Google, GA4, Meta,
    Pinterest and OpenAI together. Still open: the April audit's junk-action
    demotions. Durable reference is
    TRACKING_SYSTEM.md § Conversion Values:
    value carries real basket money or a measured expected value, never an
    amount we computed for the visitor.
  • doc/tasks/202607311447_SIDEKIQ_MEMORY_AND_WORKLOAD_HARDENING.mdactive
    ledger
    for the three-role Sidekiq rollout, memory/DB budgets, full-cycle
    verification, deploy serialization, safe PgBouncer replacement, and the
    remaining dead-set/EDI correctness follow-ups.
  • doc/tasks/202607282254_WAYFAIR_CATALOG_CLEANUP.mdactive ledger of
    everything still wrong with the Wayfair catalogue (US 7083 / CA 24331):
    coverage gaps, class re-files, variation-set fragmentation, cross-border
    segregation. Mirrors Basecamp todolist 381259655 (one todo per item/group).
    Read it when asked what's outstanding on Wayfair.
  • doc/tasks/202607181233_SUPERCRAWLER.md — unified content-fetch
    service (Heatwave::Crawler), read/probe/inspect/bypass/auto modes
    over direct → Playwright → Oxylabs tiers. Phases 1–4 on branch
    supercrawler.
  • doc/tasks/202604251235_PR480_FOLLOWUPS.md — SQL hardening,
    lease_connectionwith_connection migration, Sidekiq capsule ops
    checklist, nits deferred from the Rails 7.2 PR review.
  • doc/tasks/202604251300_DEAD_VIEWS_AND_PARTIALS_CLEANUP.md — tiered
    removal of ~700 candidate dead partials and dead view directories.
  • doc/tasks/202606050856_TIMESTAMPTZ_BACKFILL.md — bulk-op-gated plan
    to convert ~621 legacy timestamp columns to timestamptz. Not
    started; default is opportunistic conversion.

When starting a session, scan doc/tasks/ for the most recent files.


Claude Code session quality

Agent quality varies mostly on five things invisible in chat:

  1. Model selection. Opus 4.8 (1M) for non-trivial work; Sonnet 4.6
    for tight loops; Haiku 4.5 for trivial edits only. Switch with
    /model; /fast toggles Opus 4.8 fast mode.

  2. MCP servers actually loaded. .mcp.json is committed and
    .claude/settings.json sets enableAllProjectMcpServers: true, so
    every server (heatwave-production, ahrefs, playwright,
    chrome-devtools, netdata) is auto-approved. Most integrations live in
    skills, not MCPs (see the mcp-servers skill). A disconnected MCP is
    almost always missing secrets — run script/setup_mcp_servers.sh.
    Opt out team-wide via disabledMcpjsonServers in committed settings,
    personally via .claude/settings.local.json.

  3. Tool search. Committed settings set env.ENABLE_TOOL_SEARCH to
    "true" so ~1,500 MCP tool schemas are fetched on demand, not sent
    every turn. Don't turn it off.

  4. The right launcher. On macOS, click the Claude (Heatwave)
    Dock tile from bin/setup, not stock Claude.app — stock launches
    without the project env and secret-needing MCPs silently fail.

  5. The Headroom proxy is up. bin/setup installs the latest Headroom
    (context compression) default-on, applies a persistent-service proxy on
    127.0.0.1:8787, and runs headroom wrap --prepare-only for Claude,
    Codex, Kimi, and Grok. ANTHROPIC_BASE_URL still points at the proxy, so
    a bare claude dies connection-refused when the proxy is down — the
    recommended launch is claude / codex / kimi (zsh functions from
    bin/setup: headroom wrap --no-proxy --code-memory none). Serena's
    wrap-time project index hangs on this repo for up to 5 minutes, and
    a leftover [mcp_servers.serena] in ~/.codex/config.toml hangs
    Codex again on MCP handshake. bin/setup writes the wrap functions
    and strips that Codex entry. Do not run a bare headroom wrap claude|codex without --code-memory none.
    Launch Grok with headroom wrap grok. First diagnostic: headroom doctor. Opt out per-machine with HEATWAVE_SKIP_HEADROOM=1 bin/setup.
    Do not export GROK_MODELS_BASE_URL or
    GROK_MODEL_GROK_BUILD_BASE_URL in ~/.zshrc — they override Grok's
    signed-in subscription catalog (named models fail to resolve). bin/setup
    unsets them and scrubs them from shell profiles. Launch Grok via
    headroom wrap grok instead.
    bin/setup self-heals the two common breaks each run: it upgrades
    headroom via headroom update (auto-installing a mise global Rust
    when a release source-builds a dep — e.g. litellm's maturin wheel; the
    build runs in uv's temp dir so the repo's .mise.toml pin doesn't
    apply) and rebuilds broken deployment profiles via remove + re-apply (a
    stale init-user-era profile hangs install apply waiting on
    default; a pipx upgrade can strand launchd on a deleted venv). The
    case it CAN'T fix is an orphan proxy squatting :8787: it answers the
    /readyz readiness poll while launchd's com.headroom.default
    crash-loops on [Errno 48] address already in use. Kill the
    squatter — re-running bin/setup just re-runs the race:

    lsof -nP -i :8787                          # every PID on the port
    cat ~/.headroom/deploy/default/runner.pid  # the legitimate one
    kill <the other PID>                       # KeepAlive rebinds clean
    

One-time onboarding per machine: bin/setup (wrapper + Dock tile +
plugins), then script/setup_mcp_servers.sh (populates .env.mcp from
1Password). bin/setup-worktree (auto-run by the post-checkout hook)
symlinks gitignored personal files (including
.claude/settings.local.json, .grok/config.local.toml, and
.pr-ready.local) into worktrees.

Grok note: launch via headroom wrap grok so inference goes through
the Headroom proxy without persisting GROK_MODELS_BASE_URL (that override
breaks the subscription model catalog). grok -w / Ctrl+W makes
Grok-managed worktrees under ~/.grok/worktrees/. For the canonical
~/Projects/heatwave_worktrees/ dir, run bin/wt <name> first, then
headroom wrap grok inside it. The new_session_worktree_mode = "never" /
fork_worktree_mode = "never" settings in .grok/config.toml keep the TUI
from offering Grok-managed worktrees; agent-driven isolation worktrees use
scripts/worktree-create.sh.

The committed .claude/settings.json carries team allow/deny/ask
lists and hooks; defaultMode is per-dev in ~/.claude/settings.json.
.grok/config.toml is the shared Grok base; personal overrides go in the
gitignored .grok/config.local.toml.


MCP server credentials

Single source of truth across Claude Code (CLI), Claude in Zed
(claude-acp), and Cursor:

  • .mcp.json (committed) — server definitions with ${VAR}
    placeholders. Read by Claude Code natively, by Cursor via the
    .cursor/mcp.json symlink.
  • .env.mcp (gitignored) — team-shared secrets from 1Password vault
    IT, populated by script/setup_mcp_servers.sh.
  • .env.mcp.local (gitignored) — per-dev overrides (seed from
    .env.mcp.local.example). GitHub uses gh CLI auth, not a PAT here.
  • .envrc (committed) — direnv sources the two MCP files plus
    .env.db.local and .env.test.local; run direnv allow once per content
    change or fresh worktree path.

Repopulate after rotation/fresh checkout:
script/setup_mcp_servers.sh (needs op signin); verify with --check.
The full .env* map (ten files plus .envrc):
doc/development/ENVIRONMENT_FILES.md.

GUI-launched apps: the claude-desktop wrapper

Two problems: Claude Desktop reads only
~/Library/Application Support/Claude/claude_desktop_config.json (never
.mcp.json), and GUI launches get launchd's minimal env so ${VAR}
resolves empty. bin/setup (or --claude-app) fixes both: it symlinks
bin/claude-desktop into ~/bin/ (sources
.env.mcp + .env.mcp.local, execs Claude directly so env propagates),
builds and Dock-pins a Claude (Heatwave).app via
script/build-claude-dock-app.sh, and
mirrors .mcp.json into claude_desktop_config.json via
script/sync_claude_desktop_config.sh
(substitutes ${VAR}, resolves bare commands to mise-shim paths, drops
"disabled": true entries, replaces only mcpServers, backs up to
.bak; re-run with bin/setup --refresh-mcp after rotation).

Click the "Claude (Heatwave)" tile or run claude-desktop — the
stock tile bypasses the wrapper. Quit any running Claude.app first
(macOS allows one instance). bin/setup also removes the old
launchd-based env bridge; direnv wins for terminal sessions.

Worktrees

.env.mcp* are gitignored and don't follow git worktree add.
bin/setup-worktree symlinks them (plus master.key, database.yml,
etc.) from the main checkout, creates gitignored runtime dirs (log/,
tmp/, storage/), and runs mise trust plus direnv allow. The
Lefthook-managed post-checkout job (lefthook.yml invokes
bin/githooks/post-checkout; installed by bin/setup) automatically runs
bin/setup-worktree --skip-deps, so it does not run bundle install or
yarn install. Run unflagged bin/setup-worktree afterward to install both.
As a fallback, .envrc runs the same lightweight bootstrap on first cd-in if
.env.mcp is missing. Both automatic paths gate on .env.mcp existence —
idempotent no-ops elsewhere.

Subagents resolve relative paths against the session cwd, not the
worktree a Bash call cd-ed into (Bash cwd resets per call; the session
root never moves). Relative paths in a subagent or workflow prompt edit
the main checkout — on master, where other sessions are working. Pass
absolute worktree paths, and git status the main checkout afterwards.

Never symlink the whole .claude/ directory into a worktree. It
mixes three kinds of file: tracked ones that must materialize from git
per-worktree (settings.json, skills, scripts/, launch.json),
gitignored personal ones bin/setup-worktree symlinks individually
(settings.local.json), and gitignored per-worktree runtime state
that must not be shared (worktrees/, scheduled_tasks.lock,
.headroom_wrap_marker.json). A whole-dir symlink breaks the first and
corrupts the third.

Zed context_servers must stay empty

Zed's Claude Agent panel forwards every context_servers entry to the
spawned claude via --mcp-config '{…}', which replaces .mcp.json
— one entry hides every project MCP. Leave "context_servers": {}; put
MCPs in .mcp.json only (including Headroom's MCP). There is no
headroom wrap zed: ACP streams through :8787 return HTTP 200
{"errorKind":"unknown"}, so Zed uses native provider APIs and calls
headroom_compress / headroom_retrieve / headroom_stats on demand.

bin/setup / script/setup_zed_acp.py merge a Headroom-bypass env
into ~/.config/zed/settings.json without touching personal ACP
options (models, effort, extra registry agents, custom kimi acp /
grok agent stdio commands). Verify:

ps -ax | grep claude-agent-sdk | grep -v grep
# the spawned `claude` should NOT have `--mcp-config` on its command line

Conventions worth highlighting

  • coder: keyword for serialize: serialize :foo, Hash and
    serialize :foo, coder: Hash are both wrong on Rails 7.0+ (a class
    isn't a serializer without .dump/.load). Use coder: YAML or
    HashSerializer (app/serializers/hash_serializer.rb) plus type:.
  • normalizes :foo, with: ->(v) { Heatwave::Normalizers.… } for
    attribute normalization (Rails 7.1 native, replacing the normalizr
    gem). app/lib/heatwave/normalizers.rb has every domain normalizer
    (:phone, :zip_or_postal_code, :html_scrubber, :tagify,
    :hash_compactor, …); default chain: Heatwave::Normalizers.default(v);
    named chain: Heatwave::Normalizers.chain(v, :strip, :blank, :downcase).
    Don't reintroduce the singular normalize DSL.
  • enum syntax is positional: enum :status, [:draft, :published],
    not enum status: [...].
  • ActiveRecord::Base.lease_connection replaces deprecated
    connection; prefer with_connection { |conn| … } for short-lived
    queries.
  • errors.add(:foo, "msg") — never errors[:foo] << "msg" (silent
    no-op since AR 6.1).
  • .to_b for boolean coercion (the wannabe_bool gem, in the
    Gemfile) over ActiveModel::Type::Boolean.new.cast — 193 files vs 11.
    Use it for loose/serialized values (Sidekiq string args, params, ENV).
    Nuance: .cast(nil)nil but nil.to_bfalse.
  • Time.current / Date.current, not Time.now / Date.today
    (server is America/Chicago).
  • Ponytail (DietrichGebert/ponytail)
    is the default coding posture on every Heatwave AI CLI (Claude, Codex,
    Kimi, Grok). Shortest working diff after you understand the problem;
    never skip validation, security, or an explicitly requested check.
    Installed by bin/setup. /ponytail lite|full|ultra|off to change
    intensity.

Resolving PR review comments

When addressing PR review feedback (CodeRabbit, humans, yard-lint),
fix every reported issue in one pass — don't pre-filter into "in
scope" / "out of scope" buckets, even when the file was only touched by
a mechanical change. Pre-existing debt surfaced by review gets closed
while the file is open (mirrors the documentation-conventions rule:
remove a touched file's entries from .yard-lint-todo.yml).

If a finding genuinely can't be addressed (behavior change, missing
info, conflicts with in-flight work), surface that one case to the user
rather than batch-declining.

Communicating with the user

  • Be terse. State results, not narration.
  • For code changes, follow Branching — stack onto the open PR, and never
    merge to master unprompted.
  • File follow-up plans in doc/tasks/ rather than long chat replies.
  • When in doubt about a destructive or shared-state action (push, delete
    branch, force-push, drop table, send external message), ask first.