Agent Instructions for warmlyyours/heatwave
Single source of truth for agent context across Claude Code, Aider, OpenAI
Codex CLI, Cursor (0.50+), and any other tool that honors the AGENTS.md
convention (https://agents.md/). CLAUDE.md is a symlink to this file.
Skills live under .agents/skills/ (the
agents.md convention) and are reached by Claude
Code via the .claude/skills symlink. First-party skills authored in
this repo and third-party skills vendored via skills-lock.json
coexist in the same tree. There is no separate Cursor rules tree any
more; the hard rules below are loaded by every tool that reads
AGENTS.md, and the intent-triggered rules are all skills.
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
will pick up system-wide versions and fail with a mismatch:
# CORRECT
mise exec -- bin/rails runner script.rb
mise exec -- bundle exec rails test
mise exec -- yarn build
# WRONG — picks up system Ruby
bin/rails runner script.rb
bundle exec rails test
Other toolchain hard rules:
- Package manager is Yarn only. Do not use
npm. - Test framework is Minitest under
test/. Do not introduce RSpec. - Database is PostgreSQL with
db/structure.sqlas the schema source
(schema_format = :sql).db/schema.rbdoes not exist by design.
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 allowed without asking — run it freely to apply migrations and regenerate db/structure.sql. Prod migrations go through bin/deploy (already blocked); a RAILS_ENV=production … db:migrate still needs explicit permission. Rollback / redo still ask (they revert data) regardless of env. |
rails server |
Ask first |
rails console |
Breaks the environment in this repo |
bin/deploy |
Always ask |
git push --force to master |
Always warn |
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 — there is no longer a separate
rationale file.
Bulk operations — anything affecting >1000 records requires two confirmations
Mass-affecting operations (database UPDATEs / DELETEs, Sidekiq enqueues,
external-API loops, mass file writes, etc.) where the row / job count
could exceed 1000 must go through this protocol:
- Count first, run second. Before drafting any code that loops over
records, compute and surface the count. Show the SQL or Ruby that
produces the count, the result, and the date / scope filters you
used. No estimating — actual count from the actual scope. - Two explicit confirmations. After showing the count, ask the
user to confirm twice — separated by another clarifying question
(scope, window, API quota implications, blast radius). Do not
bundle the two confirmations into one "are you sure (yes/no)"
prompt. The second confirmation should follow the user thinking
about the implication of the first answer. - Default to NARROW scope. When the user asks for "all" or "100%"
coverage, default to a recent window (last 4 months, or the
smallest window that satisfies the stated goal) and have the user
explicitly widen it if needed. Volume tends to surprise everyone. - External-system cost surfaced. If the operation hits a
rate-limited or billed external API (ShipEngine, Stripe, SP-API,
etc.), state the per-call cost / quota implication in the
confirmation. "315k tracking.start calls will consume ~X% of
ShipEngine quota and generate ~94k AppSignal warnings for the
expected rejection rate" is a confirmation. "Run this?" is not. - No undo, no run. If there is no clean way to undo the operation
(e.g. Sidekiq jobs cannot be selectively pruned from a shared
:defaultqueue without LREM-per-job which is O(n²) in Redis), the
protocol applies even more strictly — establish the cleanup story
BEFORE enqueueing, not after.
Surfaced on 2026-05-29 after a 315k-job Sidekiq enqueue against
ShipmentTrackingRegistrationWorker blew up because the scope was
"every parcel shipment ever" instead of "shipments still inside the
carrier's tracking-retention window." Cleanup required killing
Sidekiq processes to avoid wiping unrelated jobs in :default. Never
again.
Code deletion safety
Before deleting a concern, method, class, or constant, search for
references first. Don't trust "looks unused" — dynamic dispatch hides
plenty of callers.
rg "method_name" app/ lib/ test/
Also check for send(:foo) / public_send and any metaprogramming
that could invoke the symbol indirectly. If anything turns up, list the
locations to the user before proceeding.
For associations, grep is especially unreliable —
joins/includes/eager_load/preload and nested through-associations
reference them by symbol. Before deleting one, mark it deprecated: true
(Rails 8.1) and let real traffic prove it's dead:
has_many :legacy_widgets, deprecated: true # boolean only
The reporting mode is global (per env via
config.active_record.deprecated_associations_options): :raise in
dev/test so tests that still touch it fail loudly, :notify in
staging/production where
config/initializers/350_deprecated_association_reporter.rb forwards every
hit to AppSignal (grouped per association). Watch the count for one release
cycle, delete once it reaches zero. See the
god-object-decomposition
skill for the full workflow.
Migration safety
Use the Rails generator for migration filenames. Round-number
timestamps like 20260310000000 collide silently after merges and
cause ActiveRecord::DuplicateMigrationNameError.
mise exec -- bin/rails generate migration AddColumnToTable
If you absolutely must create one by hand, use date +%Y%m%d%H%M%S.
Migration version must match the Rails version. This project runs
Rails 8.1; new migrations inherit from ActiveRecord::Migration[8.1].
The generator stamps this automatically.
Guard hardcoded record IDs in data migrations. Production has
records that dev/CI don't, and vice versa. Use find_by (returns nil)
not find (raises), guard with if record / next unless record,
and verify both sides exist before creating 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?).
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 now emit
timestamp with time zone. This follows
PostgreSQL's recommendation
and rails/rails#41084. The
flip changes the DDL for new columns only — the ~40 legacy tables
stay timestamp without time zone, and that's fine on disk because
Rails stores them in UTC (default_timezone is :utc). The schema is
deliberately mixed; don't "fix" the old columns wholesale. Use
t.timestamp / t.timestamptz when you need to override the default
explicitly on a single column.
But the flip has a read-side trap that the same initializer now
guards against: datetime_type = :timestamptz re-aliases which Postgres
type maps to the :datetime symbol (via
OID::DateTime#real_type_unless_aliased). After the flip, timestamptz
columns report :datetime (timezone-aware), but legacy timestamp
columns report :timestamp — which is not in the default
time_zone_aware_types ([:datetime, :time] + :timestamptz from the
AR railtie). Left unguarded, every legacy timestamp column reads back
as a bare UTC Time instead of a Time.zone (America/Chicago)
TimeWithZone, so views render UTC. The initializer therefore appends
:timestamp to time_zone_aware_types. Don't remove that line, and if
you ever add a new datetime type symbol, make sure it's in that list.
Converting an existing column is a full-table rewrite — treat it as
one. Don't reach for it casually; a naive
ALTER COLUMN … TYPE timestamptz reinterprets values using the
session time zone and silently corrupts data. Always pin the source
zone (our naive values are UTC) and remember the ACCESS EXCLUSIVE
lock + table rewrite — route large tables through online_migrations:
# ✅ explicit source zone; safe for UTC-stored naive timestamps
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 or module under a namespace that matches an ActiveRecord
model — class ModelName::SomethingElse in a model_name/ directory —
is fine on Rails 8.1 / Zeitwerk 2.6. The model file defines the
constant and directories in other autoload paths extend it; dozens run
in production today (Coupon::*, Quote::*, Catalog::*,
Customer::*, …) and bin/rails zeitwerk:check passes clean.
Prefer namespacing — keep domain code grouped under its model.
The one real failure mode is narrow and gem-specific: a gem or framework
that resolves the bare top-level constant during eager-load and gets
Zeitwerk's implicit-namespace module instead of your model class. The
known case is the noticed gem — it runs
Class.new(const_get(:Notification)), so any notification/ directory
under an autoload path makes eager-load raise
TypeError: superclass must be an instance of Class. Today only
Notification is affected.
# ❌ only because the `noticed` gem 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. It
catches exactly these gem collisions and is silent on the safe nestings.
If it fails for a model-named namespace, flatten that one class — do
not flatten namespaces wholesale.
History: Party (Jan) and CallRecord (Feb) crashed once each on Rails 7 /
older Zeitwerk (AppSignal #1371), which led to an over-broad "never
nest" rule and a flattening sweep. Neither reproduces on 8.1
(probe-verified 2026-06-14); only the noticed/Notification collision
is live. The sweep is being reverted — prefer restoring namespaces.
Git commit technique
Multi-line commit messages via cat <<'EOF' heredocs fail silently
in sandboxed shells — the message comes out empty or wrong. 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, always verify ownership and unpushed state:
git log -1 --format='%an %ae' # confirm you authored it
git status # confirm "ahead" — not yet pushed
Branching
- Default base is
master. The preferred and recommended workflow is
to open a short-lived branch offmasterper change and submit a PR. - Direct merges to
master(fast-forward push from a local branch) are
allowed when the author chooses to skip review — typically for small,
low-risk changes (tooling tweaks, doc fixes, trivial follow-ups).
Agents should still default to PRs and only push direct on explicit
user authorization. Force-push tomasterremains prohibited (see
hard-block table above).
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/, follow the
Agent Skills standard (https://agentskills.io)
and the agents.md (https://agents.md/) layout.
First-party + vendored skills live here.
.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).
db/structure.sql Schema source of truth.
.mise.toml Toolchain pins.
When working on a feature, check whether a relevant skill exists before
writing code. Skills carry hard-won conventions (strong params, Turbo
response patterns, state machine validations, etc.) that the agent should
follow rather than rediscover.
Adding a skill
- Create
.agents/skills/<name>/SKILL.mdwithagentskills.io
frontmatter (name,description, optionallylicense,
compatibility,metadata,allowed-tools). - List the skill name in the index below.
Claude Code discovers skills via the .claude/skills symlink, which
resolves to .agents/skills/. Zed reads .agents/skills/
natively — no symlink or config —
so the same library powers both editors. Vendored third-party skills
(pulled in via npx <skill>@latest install and tracked in
skills-lock.json) land in the same tree, so a single discovery root
covers everything.
Run script/validate_skills.sh to check every skill against the
spec (frontmatter present, name matches directory and conforms to
the regex, description ≤ 1024 chars) and the local best-practice
budget (SKILL.md ≤ 500 lines — past that, move detail into
references/ per progressive disclosure). The validator exits 0
when all skills are compliant; length warnings are advisory.
Skill index
Skills follow the Agent Skills standard — each
is a directory containing SKILL.md (frontmatter + body) 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 |
| Hotwire / UI | view-components, forms, stimulus, turbo-streams, carousels, ui-conventions, no-inline-view-scripts, server-to-client-data, tag-helpers, render-partials |
| Hotwire content packs | hwc-forms-validation, hwc-media-content, hwc-navigation-content, hwc-realtime-streaming, hwc-stimulus-fundamentals, hwc-ux-feedback |
| CRM | crm-pages |
| Auth / OAuth | oauth-clients |
| Background work | background-jobs, webhooks, mcp-servers |
| Content | blog-content, svgmaker |
| Quality | code-quality-audit, rails-audit-thoughtbot, coderabbit-cli, testing, system-tests, security, tracking-consent |
| Docs | documentation-conventions, yard-model-surface-playbook |
| Edge / SEO / perf | cloudflare-redirects, cloudflare-cache-purge, lighthouse-cli, pagespeed-insights, screaming-frog-cli, keywordspeopleuse |
| 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, 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 |
Rules index
The hard rules are the bodies inlined above (toolchain, hard-block
commands, code deletion, migration safety, Zeitwerk flat namespace,
git commit technique). Everything that used to be an intent-triggered
.cursor/rules/*.mdc has been promoted to a skill under
.agents/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/ |
When you need to add a new rule or convention, write it as a skill —
do not reintroduce a separate .cursor/rules/ tree or per-tool
config file.
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 across tools.
Recent task docs to be aware of:
doc/tasks/202604251235_PR480_FOLLOWUPS.md— SQL hardening,
lease_connection→with_connectionmigration, Sidekiq capsule ops
checklist, and minor nits deferred from the Rails 7.2 PR review.doc/tasks/202604251300_DEAD_VIEWS_AND_PARTIALS_CLEANUP.md— tiered
plan for removing ~700 candidate dead partials and a few definitively
dead view directories.doc/tasks/202606050856_TIMESTAMPTZ_BACKFILL.md— bulk-op-gated,
tiered plan for converting the ~621 legacytimestamp without time zonecolumns totimestamptz. NOT started; default is opportunistic
conversion, full sweep documented for when/if we commit to it.
When starting a session, scan doc/tasks/ for the most recent files to
understand what's queued.
Claude Code session quality
Devs have reported wildly different agent quality on this repo — same
prompts, same files, opposite outcomes. Most variation reduces to four
things that aren't visible in the chat:
-
Model selection. Opus 4.7 (1M) for non-trivial work; Sonnet 4.6
for tight loops where you want speed over reasoning; Haiku 4.5 only
for trivial edits. Switch with/model. A Sonnet/Haiku session on a
hard task will feel like "Claude is dumb today" — it's not, it's
the model./fasttoggles Opus 4.6 fast mode (still Opus, just
quicker; not a downgrade). -
MCP servers actually loaded.
.mcp.jsonis committed and
.claude/settings.jsonsetsenableAllProjectMcpServers: trueso
every server in.mcp.json(heatwave-production, ahrefs, playwright,
chrome-devtools, netdata + netdata-replica)
is auto-approved for every dev — no per-developer enable list to
drift. Most external integrations now live in skills, not MCPs (see
themcp-serversskill); the remaining MCPs cover Ahrefs (large
tool surface, no good skill alternative), Heatwave's own gateway,
Playwright (Docker-based browser automation), Chrome DevTools
for Agents (on-host Chrome via CDP — performance traces, Lighthouse,
a11y snapshots, network/memory inspection; backs the
chrome-devtoolsskill family, needs no secrets, requires a local
Chrome), and Netdata (the two on-host agents' built-in MCP — read-only
metrics/logs/alerts/anomaly queries over the tailnet at:19999/mcp,
local mode; seeconfig/netdata/README.md). If an MCP shows
as not connected,
the issue is almost always missing secrets — see MCP server
credentials below and runscript/setup_mcp_servers.sh. To opt out
of a specific server team-wide, add it todisabledMcpjsonServersin
the committed settings; to opt out personally, add it there in
.claude/settings.local.json. -
Tool search. The committed settings set
env.ENABLE_TOOL_SEARCH
to"true", so tool schemas are fetched on demand instead of all
~1,500 MCP tool schemas being sent into context every turn. Without
it, the model is distracted and slower. Don't override this off. -
The right launcher. On macOS, click the Claude (Heatwave)
Dock tile thatbin/setupinstalls, not the stock Claude.app tile.
Stock Claude.app launches without the project env, so every MCP that
needs a secret silently fails. Same symptom as #2 from a different
angle. Details in MCP server credentials → GUI-launched apps.
One-time onboarding per machine:
bin/setup # wrapper + Dock tile + plugins
script/setup_mcp_servers.sh # populate .env.mcp from 1Password
If you fork to a worktree, bin/setup-worktree (auto-triggered by the
post-checkout hook) symlinks the gitignored personal files, including
.claude/settings.local.json, so your worktree starts with the same
agent environment as the main checkout.
The committed .claude/settings.json carries the team's allow/deny/
ask lists and hooks. defaultMode is left to each dev's user-global
~/.claude/settings.json (typical choices: default, acceptEdits,
bypassPermissions).
MCP server credentials
Single source of truth for MCP servers across Claude Code (CLI), Claude
Code in Zed (claude-acp), and Cursor:
.mcp.json(project root, committed) — server definitions with${VAR}
placeholders for every secret. Read by Claude Code natively and by
Cursor through the symlink at.cursor/mcp.json..env.mcp(gitignored) — team-shared secrets, populated from 1Password
vaultITbyscript/setup_mcp_servers.sh..env.mcp.local(gitignored) — per-developer MCP secret overrides.
Currently empty by default; reserved for future per-dev credentials.
Seed it from.env.mcp.local.example. GitHub access is handled via
theghCLI's own auth, not a PAT here..envrc(committed) —direnvconfig that sources both env files. You
only needdirenv allowonce per.envrccontent change (or per fresh
worktree path); after that direnv auto-loads on every cd into the project.
To (re)populate after a credential rotation or on a fresh checkout, run
script/setup_mcp_servers.sh (requires op signin). Use
script/setup_mcp_servers.sh --check to verify the env is wired up.
The full .env* map (this project has seven .env* files plus .envrc,
each with a different consumer) lives at
doc/development/ENVIRONMENT_FILES.md.
GUI-launched apps: the claude-desktop wrapper
Two distinct problems have to be solved for Claude Desktop:
- Claude Desktop does not read
.mcp.json. Project-level.mcp.json
is a Claude Code / Cursor / Zed convention; Claude Desktop reads
server definitions exclusively from
~/Library/Application Support/Claude/claude_desktop_config.json.
So a fresh dev machine sees zero MCP servers in Settings → Developer
even with.mcp.jsoncommitted at the project root. - GUI launches strip the shell env. Apps launched from
Spotlight/Dock/Finder get launchd's minimal env, so any${VAR}
references in an MCP entry resolve to empty when the app spawns its
MCP children.
bin/setup (and bin/setup --claude-app) addresses both, in order:
- Symlinks
bin/claude-desktopinto~/bin/so you
can launch Claude from a terminal with the project env preloaded. The
wrapper sources.env.mcpand.env.mcp.localfrom the main checkout
and execs/Applications/Claude.app/Contents/MacOS/Claudedirectly
(bypassing LaunchServices), so the env propagates into every MCP child
Claude spawns. - Builds a
Claude (Heatwave).appbundle in~/Applications/via
script/build-claude-dock-app.sh.
The bundle'sCFBundleExecutableis a launcher script that execs the
wrapper; the icon is reused from/Applications/Claude.appso the two
Dock tiles look identical — the name "Claude (Heatwave)" in the tooltip
and Launchpad is what distinguishes them. - Pins that .app to the Dock via
dockutil(added to the Brewfile). - Mirrors
.mcp.jsonintoclaude_desktop_config.jsonvia
script/sync_claude_desktop_config.sh.
Reads.mcp.json, substitutes${VAR}/$VARfrom.env.mcp+
.env.mcp.local, resolves bare commands (uvx,npx,mise,
docker, …) to absolute paths via the version-stable mise shims dir
(Claude Desktop launched from the Dock has only the system default
PATH), drops"disabled": trueentries, and merges into the target
file replacing only themcpServerskey. Existing top-level keys
(preferences, window-state, chromeExtension, …) are preserved.
Backs up the previous file to.bak. Re-run via
bin/setup --refresh-mcpafter a credential rotation.
Click the "Claude (Heatwave)" Dock tile (or run claude-desktop from
a terminal) instead of the stock Claude.app tile — the stock one bypasses
the wrapper and the MCP env. Quit any already-running Claude.app first,
since macOS only allows one instance.
This replaces an earlier launchd-based env bridge
(~/Library/LaunchAgents/com.warmlyyours.mcp-env.plist + script/mcp_env_loader.sh)
that raced with Claude Desktop's auto-launch at login and left it with an
empty env until the next manual restart. bin/setup removes the old
LaunchAgent on machines that still have it. Direnv still wins inside the
project for terminal sessions — it loads both env files and overrides any
inherited launchd state.
Worktrees
.env.mcp and .env.mcp.local are gitignored and don't follow git worktree add. A fresh worktree starts with no MCP secrets. bin/setup-worktree
symlinks them (plus master.key, database.yml, etc.) from the main
checkout, ensures Rails runtime dirs exist (log/, tmp/, storage/ are
gitignored and also missing on fresh worktrees), and runs mise trust,
mise exec -- bundle install, mise exec -- yarn install, and
direnv allow.
Two automatic triggers run it for you, so creating a worktree from Zed's
agent panel (or anywhere else) needs no manual mise trust / bin/setup
follow-up:
post-checkoutGit hook (bin/githooks/post-checkout) — fires when
git worktree addcreates the new tree. Activated by
git config core.hooksPath bin/githooks, set once bybin/setup.
Worktrees share.git/configwith the main checkout, so configuring it
on the main clone propagates to every worktree..envrcfallback — invokesbin/setup-worktreeon first cd-in if
.env.mcpis still missing (covers worktrees created before the hook
was wired up).
Both gate on .env.mcp existence, so they're idempotent no-ops in the
main checkout and on every subsequent git checkout.
Zed context_servers must stay empty
Zed's "Claude Agent" panel launches @agentclientprotocol/claude-agent-acp,
which forwards every entry in ~/.config/zed/settings.json context_servers
to the spawned claude via --mcp-config '{…}'. That flag replaces
.mcp.json, it does not merge. A single server in context_servers makes
every project MCP invisible to the agent. Rule: leave "context_servers": {}
in Zed; put MCPs in .mcp.json only.
To verify after a session starts:
ps -ax | grep claude-agent-sdk | grep -v grep
# the spawned `claude` should NOT have `--mcp-config` on its command line
Conventions worth highlighting
These come up enough that agents repeatedly miss them:
coder:keyword forserializein models.serialize :foo, Hash
(pre-7.0 form) andserialize :foo, coder: Hash(passing a class as
a coder) are both wrong on Rails 7.0+ — the class isn't a serializer
unless it implements.dump/.load. Usecoder: YAMLor
HashSerializer(inapp/serializers/hash_serializer.rb) and pass
type:for the type constraint.normalizes :foo, with: ->(v) { Heatwave::Normalizers.… }for
attribute normalization. Heatwave migrated off thenormalizrgem to
Rails 7.1 nativenormalizes— the helper module
app/lib/heatwave/normalizers.rbprovides every domain-specific
normalizer that used to live inconfig/initializers/normalizr.rb
(:phone,:zip_or_postal_code,:html_scrubber,:tagify,
:hash_compactor, etc.). For the global default chain
([:strip, :blank]) callHeatwave::Normalizers.default(v); for a
named chain callHeatwave::Normalizers.chain(v, :strip, :blank, :downcase). Don't reintroduce the singularnormalizeDSL.enumsyntax is positional:enum :status, [:draft, :published],
notenum status: [:draft, :published].ActiveRecord::Base.lease_connectionreplaces deprecated
connection.with_connection { |conn| … }is the better pattern
for short-lived queries (releases the connection back to the pool).errors.add(:foo, "msg")— nevererrors[:foo] << "msg"(silent
no-op since AR 6.1).Time.current/Date.currentin this codebase, notTime.now/
Date.today(server isAmerica/Chicago).
Resolving PR review comments
When asked to address PR review feedback (CodeRabbit, human reviewers,
yard-lint, etc.), fix every reported issue in one pass. Do not
pre-filter findings into "in scope" / "out of scope" buckets and decline
some unilaterally — even if the file was only "touched" by an unrelated
mechanical change (a frozen_string_literal autocorrect, a rename
sweep, etc.), pre-existing debt that the review surfaces should be
closed while the file is open.
This matches the project's documentation-conventions skill rule:
"As you touch a file, remove its entries from .yard-lint-todo.yml
so yard-lint enforces docs on it from then on."
If a single finding genuinely cannot be addressed (would change
behavior, requires information you don't have, conflicts with
in-flight work on another branch), surface that one case to the user
rather than batch-declining.
Communicating with the user
- Be terse. State results, not narration.
- For code changes, branch off
masterand open a PR per change. - 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.