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.sql as the schema source
    (schema_format = :sql). db/schema.rb does 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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
    :default queue 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 off master per 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 to master remains 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

  1. Create .agents/skills/<name>/SKILL.md with agentskills.io
    frontmatter (name, description, optionally license,
    compatibility, metadata, allowed-tools).
  2. 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_connectionwith_connection migration, 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 legacy timestamp without time zone columns to timestamptz. 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:

  1. 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. /fast toggles Opus 4.6 fast mode (still Opus, just
    quicker; not a downgrade).

  2. MCP servers actually loaded. .mcp.json is committed and
    .claude/settings.json sets enableAllProjectMcpServers: true so
    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
    the mcp-servers skill); 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-devtools skill 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; see config/netdata/README.md). If an MCP shows
    as not connected,
    the issue is almost always missing secrets — see MCP server
    credentials
    below and run script/setup_mcp_servers.sh. To opt out
    of a specific server team-wide, add it to disabledMcpjsonServers in
    the committed settings; to opt out personally, add it there in
    .claude/settings.local.json.

  3. 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.

  4. The right launcher. On macOS, click the Claude (Heatwave)
    Dock tile that bin/setup installs, 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
    vault IT by script/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
    the gh CLI's own auth, not a PAT here.
  • .envrc (committed) — direnv config that sources both env files. You
    only need direnv allow once per .envrc content 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:

  1. 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.json committed at the project root.
  2. 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:

  1. Symlinks bin/claude-desktop into ~/bin/ so you
    can launch Claude from a terminal with the project env preloaded. The
    wrapper sources .env.mcp and .env.mcp.local from the main checkout
    and execs /Applications/Claude.app/Contents/MacOS/Claude directly
    (bypassing LaunchServices), so the env propagates into every MCP child
    Claude spawns.
  2. Builds a Claude (Heatwave).app bundle in ~/Applications/ via
    script/build-claude-dock-app.sh.
    The bundle's CFBundleExecutable is a launcher script that execs the
    wrapper; the icon is reused from /Applications/Claude.app so the two
    Dock tiles look identical — the name "Claude (Heatwave)" in the tooltip
    and Launchpad is what distinguishes them.
  3. Pins that .app to the Dock via dockutil (added to the Brewfile).
  4. Mirrors .mcp.json into claude_desktop_config.json via
    script/sync_claude_desktop_config.sh.
    Reads .mcp.json, substitutes ${VAR} / $VAR from .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": true entries, and merges into the target
    file replacing only the mcpServers key. Existing top-level keys
    (preferences, window-state, chromeExtension, …) are preserved.
    Backs up the previous file to .bak. Re-run via
    bin/setup --refresh-mcp after 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:

  1. post-checkout Git hook (bin/githooks/post-checkout) — fires when
    git worktree add creates the new tree. Activated by
    git config core.hooksPath bin/githooks, set once by bin/setup.
    Worktrees share .git/config with the main checkout, so configuring it
    on the main clone propagates to every worktree.
  2. .envrc fallback — invokes bin/setup-worktree on first cd-in if
    .env.mcp is 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 for serialize in models. serialize :foo, Hash
    (pre-7.0 form) and serialize :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. Use coder: YAML or
    HashSerializer (in app/serializers/hash_serializer.rb) and pass
    type: for the type constraint.
  • normalizes :foo, with: ->(v) { Heatwave::Normalizers.… } for
    attribute normalization. Heatwave migrated off the normalizr gem to
    Rails 7.1 native normalizes — the helper module
    app/lib/heatwave/normalizers.rb provides every domain-specific
    normalizer that used to live in config/initializers/normalizr.rb
    (:phone, :zip_or_postal_code, :html_scrubber, :tagify,
    :hash_compactor, etc.). For the global default chain
    ([:strip, :blank]) call Heatwave::Normalizers.default(v); for a
    named chain call 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: [:draft, :published].
  • ActiveRecord::Base.lease_connection replaces deprecated
    connection. with_connection { |conn| … } is the better pattern
    for short-lived queries (releases the connection back to the pool).
  • errors.add(:foo, "msg") — never errors[:foo] << "msg" (silent
    no-op since AR 6.1).
  • Time.current / Date.current in this codebase, not Time.now /
    Date.today (server is America/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 master and 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.