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.sqlas schema source
(schema_format = :sql);db/schema.rbdoes 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:
- Behavior changes use the
testingskill. 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% acrossapp/,
lib/, and Ruby scripts underscript/. - Run
bin/pr-readyin 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;noneskips models). Use--quickwhen no Ruby behavior
changed. For documentation-only changes and genuinely trivial typo/copy
edits, a developer may skipbin/pr-readyand 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. - 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. - 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. - 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:
- 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. - Two explicit confirmations, separated by another clarifying
question (scope, window, quota, blast radius). Not one bundled
"are you sure". - 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. - Surface external-system cost. For rate-limited/billed APIs
(ShipEngine, Stripe, SP-API, …), state per-call cost/quota
implications in the confirmation. - No undo, no run. Establish the cleanup story BEFORE enqueueing
(e.g. Sidekiq jobs can't be selectively pruned from a shared
:defaultqueue).
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.
- API. Ours or theirs. Scriptable, reviewable, repeatable.
- 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. - Support ticket. Slow, and someone else's queue.
- 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 →
migrate → schema: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
masteronly 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
masteron your own initiative — nogh pr merge,
no branch→masterpush, not even to unblock the user (have them
git checkout <branch>instead; adddb:migrateif 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
masterstays prohibited either way. gh pr merge --squash --delete-branchfrom a linked worktree
printsfatal: 'master' is already used by worktree at …. The
squash-merge and the remote-branch delete both succeeded — only gh's
localgit checkout mastercleanup failed, because the main worktree
holdsmaster. Don't re-run it; confirm with
gh pr view <n> --json state→MERGED. 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 orstandards/tree. - Long-form architecture, runbooks, ADRs →
doc/(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
- Create
.agents/skills/<name>/SKILL.mdwith agentskills.io
frontmatter (name,description, optionallicense,
compatibility,metadata,allowed-tools). - 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.md— active ledger for the
.yard-lint-todo.ymlburn-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.md— active 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 needsop: deleteWITH the
selector in the value; andenforcement: nullmeans a code is cosmetic
(90225) whileATTRIBUTE_SUPPRESSEDmeans the listing is not sellable
(100907). Read it before touching Amazon listing data.doc/tasks/202608041649_CONVERSION_VALUE_CORRECTION.md— active ledger
for the ad-conversion value fix (PR #1878, #1935). Code shipped,
generate_leadwent primary 2026-08-05, and the values now live inSetting
(scopead_conversion_values) editable at /crm/settings/ad-conversion-values
—ad_conversion_lead_value200,ad_conversion_quote_value100,
ad_conversion_opportunity_rate0.2 (the last read by all five offline ad
reporters, which used to each carry their own copy).Settingis 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:
valuecarries real basket money or a measured expected value, never an
amount we computed for the visitor.doc/tasks/202607311447_SIDEKIQ_MEMORY_AND_WORKLOAD_HARDENING.md— active
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.md— active 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_connection→with_connectionmigration, 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 legacytimestampcolumns totimestamptz. 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:
-
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;/fasttoggles Opus 4.8 fast mode. -
MCP servers actually loaded.
.mcp.jsonis committed and
.claude/settings.jsonsetsenableAllProjectMcpServers: true, so
every server (heatwave-production, ahrefs, playwright,
chrome-devtools, netdata) is auto-approved. Most integrations live in
skills, not MCPs (see themcp-serversskill). A disconnected MCP is
almost always missing secrets — runscript/setup_mcp_servers.sh.
Opt out team-wide viadisabledMcpjsonServersin committed settings,
personally via.claude/settings.local.json. -
Tool search. Committed settings set
env.ENABLE_TOOL_SEARCHto
"true"so ~1,500 MCP tool schemas are fetched on demand, not sent
every turn. Don't turn it off. -
The right launcher. On macOS, click the Claude (Heatwave)
Dock tile frombin/setup, not stock Claude.app — stock launches
without the project env and secret-needing MCPs silently fail. -
The Headroom proxy is up.
bin/setupinstalls the latest Headroom
(context compression) default-on, applies a persistent-service proxy on
127.0.0.1:8787, and runsheadroom wrap --prepare-onlyfor Claude,
Codex, Kimi, and Grok.ANTHROPIC_BASE_URLstill points at the proxy, so
a bareclaudedies connection-refused when the proxy is down — the
recommended launch isclaude/codex/kimi(zsh functions from
bin/setup:headroom wrap --no-proxy --code-memory none). Serena's
wrap-timeproject indexhangs on this repo for up to 5 minutes, and
a leftover[mcp_servers.serena]in~/.codex/config.tomlhangs
Codex again on MCP handshake.bin/setupwrites the wrap functions
and strips that Codex entry. Do not run a bareheadroom wrap claude|codexwithout--code-memory none.
Launch Grok withheadroom wrap grok. First diagnostic:headroom doctor. Opt out per-machine withHEATWAVE_SKIP_HEADROOM=1 bin/setup.
Do not exportGROK_MODELS_BASE_URLor
GROK_MODEL_GROK_BUILD_BASE_URLin~/.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 grokinstead.
bin/setupself-heals the two common breaks each run: it upgrades
headroom viaheadroom 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.tomlpin doesn't
apply) and rebuilds broken deployment profiles via remove + re-apply (a
staleinit-user-era profile hangsinstall applywaiting 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
/readyzreadiness poll while launchd'scom.headroom.default
crash-loops on[Errno 48] address already in use. Kill the
squatter — re-runningbin/setupjust 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.jsonsymlink..env.mcp(gitignored) — team-shared secrets from 1Password vault
IT, populated byscript/setup_mcp_servers.sh..env.mcp.local(gitignored) — per-dev overrides (seed from
.env.mcp.local.example). GitHub usesghCLI auth, not a PAT here..envrc(committed) —direnvsources the two MCP files plus
.env.db.localand.env.test.local; rundirenv allowonce 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 forserialize:serialize :foo, Hashand
serialize :foo, coder: Hashare both wrong on Rails 7.0+ (a class
isn't a serializer without.dump/.load). Usecoder: YAMLor
HashSerializer(app/serializers/hash_serializer.rb) plustype:.normalizes :foo, with: ->(v) { Heatwave::Normalizers.… }for
attribute normalization (Rails 7.1 native, replacing thenormalizr
gem).app/lib/heatwave/normalizers.rbhas 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 singularnormalizeDSL.enumsyntax is positional:enum :status, [:draft, :published],
notenum status: [...].ActiveRecord::Base.lease_connectionreplaces deprecated
connection; preferwith_connection { |conn| … }for short-lived
queries.errors.add(:foo, "msg")— nevererrors[:foo] << "msg"(silent
no-op since AR 6.1)..to_bfor boolean coercion (thewannabe_boolgem, in the
Gemfile) overActiveModel::Type::Boolean.new.cast— 193 files vs 11.
Use it for loose/serialized values (Sidekiq string args, params, ENV).
Nuance:.cast(nil)→nilbutnil.to_b→false.Time.current/Date.current, notTime.now/Date.today
(server isAmerica/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 bybin/setup./ponytail lite|full|ultra|offto 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 tomasterunprompted. - 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.