CLAUDE.md · diff

git:20260908.596ce8e to git:20260908.5c51ffe

1 added, 1 removed. Audit A to A.

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this repo is
`rails-hyperdrive` is a **dev-only Rails engine gem** that mounts an MCP (Model Context Protocol) server at `/_hyperdrive/mcp` exposing 8 introspection tools for AI coding agents, plus `hyperdrive:init` / `hyperdrive:sync` generators that discover and install four artifact kinds — **skills** (lazy), **guidelines** (eager), **agents** (Claude Code subagents), and **commands** (slash commands) — shipped by companion gems under a documented contract, and a networked `hyperdrive:discover` that suggests uninstalled companion gems for the app's stack from rubygems.
**rails-hyperdrive is the mechanism; companion gems (`rails-hyperdrive-<library>`) are the content.** The gem ships no skills or guidelines of its own — only the contract and the discovery/install engine.
This is the gem itself, **not** an app that uses it. There is no host Rails app — specs boot a tiny in-memory app via Combustion at `spec/internal/`.
`README.md` describes the user-facing golden path.
## Commands
```bash
bin/setup # bundle install
bundle exec rspec # full suite (default rake task)
bundle exec rspec spec/hyperdrive/tools_spec.rb # single file
bundle exec rspec -e "name fragment" # filter by example name
bundle exec rspec --tag smoke # opt-in end-to-end smoke (slow; ~60s first run)
bin/console # IRB with hyperdrive loaded
bin/bump patch|minor|major # bump the gem version (see Versioning)
bin/playground minimal|services|full_stack # copy a smoke fixture app to gitignored playground/<variant>/,
# bundle it against this checkout, run hyperdrive:init — for
# manual poking (--force overwrites, --no-init skips init)
# CI matrix is Ruby {3.2, 3.3, 3.4} × Rails {7.2, 8.1}.
# Reproduce a specific slot locally:
RAILS_VERSION=7.2 bundle install && RAILS_VERSION=7.2 bundle exec rspec
```
Coverage is written to `coverage/` by SimpleCov (configured in `spec/spec_helper.rb`).
## Versioning
The gem follows [Semantic Versioning](https://semver.org). `lib/rails/hyperdrive/version.rb` is the **single source of truth** — `rails-hyperdrive.gemspec` reads `Rails::Hyperdrive::VERSION` from it, as does the `describe_app` MCP tool (`mcp_server.rb`). Never hand-edit the version anywhere else.
Bump with `bin/bump <patch|minor|major|X.Y.Z>` (or `mise run bump <level>`). The script:
1. Rewrites the `VERSION` constant in `version.rb`.
2. Rolls the `## [Unreleased]` section of `CHANGELOG.md` into a dated `## [X.Y.Z]` section and refreshes the link-reference footer.
3. Prints the suggested `git commit` + `git tag` commands. Pass `--commit` to make the `chore(release): vX.Y.Z` commit, and `--tag` to also create the annotated `vX.Y.Z` tag. Use `--dry-run` to preview without writing.
Record user-facing changes under `## [Unreleased]` in `CHANGELOG.md` as you go, so a release bump just dates and tags them.
Publishing to RubyGems is tag-triggered (`bin/bump <level> --commit --tag` then push the tag). Do **not** combine `bin/bump --tag` with `rake release` — both create the `vX.Y.Z` tag. Full release runbook is in [`RELEASING.md`](RELEASING.md).
`bin/bump` covers **only the root gem**. The `bundler-hyperdrive` plugin gem (below) versions independently via `bundler-hyperdrive/lib/bundler/hyperdrive/version.rb` and has no bump script — its release is manual: edit the version, commit, push a `bundler-hyperdrive/vX.Y.Z` tag (the namespace keeps it clear of the root gem's `v*` tags), which triggers `.github/workflows/release-plugin.yml`. See `RELEASING.md`.
## Architecture
### Composition root
`lib/rails/hyperdrive/mcp_server.rb` is where everything wires together. It builds a single `MCP::Server` with the 8 tools and 2 resource families, then wraps the `StreamableHTTPTransport` in `Safety::RackMiddleware` and exposes it as a Rack app. `rack_app` is argument-free and memoized — the Origin allowlist is `Safety::RackMiddleware::DEFAULT_ALLOWED_HOSTS`, with no per-call override. The engine's `config/routes.rb` mounts that rack app at `/mcp`. `McpServer.reset!` exists for test isolation — singletons are intentional.
### The kind registry
`InstallLayout` (`lib/rails/hyperdrive/install_layout.rb`, still require-free — the MCP server loads it) is the registry every artifact kind declares itself in, and the only place a `.claude/…` path literal may appear. `KINDS` maps each type symbol to a `Kind` descriptor carrying the axes the rest of the system used to branch on: `shape` (`:dir` for skills — the only kind with supporting files and therefore the only one a manifest may gate per file — `:flat` for the rest), `section`/`key_label`/`shipped_label` (the manifest surface), `dir_key` and `prefix_key` (the manifest keys the kind reserves), `convention_roots` (a lambda over the gem name), `identity` (`:frontmatter` vs `:filename_stem`), `frontmatter` (`:required`/`:optional`), `install_body` (`:verbatim` vs `:strip_frontmatter`), `collision_rewrites_name`, `eager` (guideline only), and `dests` — a **`(kind, target)` table** whose only v1 target is `:claude`. `ARTIFACT_TYPES` (lock-kind string → type) and `dir_keys`/`dest_roots`/`content_kinds` are derived from it, so registering a kind is what wires discovery, `GemManifest` sections, the install loop, `InstallPipeline::ARTIFACT_DESTINATIONS` (the `git check-ignore` list), and the summary at once. `content_kinds` order is install order. `skill_support` is registered as a derived kind: a lock kind and a name-from-dest rule, no section and no dest of its own.
### Safety model (defense in depth)
Three layers, all keyed off `Rails::Hyperdrive.dev_mode?` (the single source of truth in `lib/rails/hyperdrive.rb`):
1. **Engine load-time warning** (`engine.rb`) — loads in any env so production boots don't blow up, just logs a warning.
2. **Rack middleware** (`safety/rack_middleware.rb`) — 403s every request outside `Rails.env.development?` or with an Origin outside the allowlist (`localhost`, `127.0.0.1`, `[::1]`).
3. **Per-tool `with_dev_guard`** (`tools/base.rb`) — catches direct invocations (tests, rake tasks) that bypass the transport.
When adding new tools, always inherit from `Tools::Base` and wrap the body in `with_dev_guard { ... }`. The block also rescues and shapes exceptions into `respond_error`.
SQL safety (`sql_safety.rb`) is a regex pair: an allowed-leader pattern (`SELECT`/`WITH...SELECT`/`EXPLAIN`/`SHOW`/`PRAGMA`) plus a forbidden-token denylist (to catch a `DELETE` smuggled inside a CTE), and one extra rule: a `PRAGMA` statement containing `=` is refused as an assignment, while the paren form (`PRAGMA table_info(users)`) and bare reads pass — the two cannot be told apart otherwise. It is a **guardrail against accidental AI damage, not a sandbox** — the user has root on their dev DB — and it does not parse: a mutation keyword inside a string literal (`SELECT 'update me'`) is refused by design.
### Shared state between generator and runtime
`StackProfile` (`lib/rails/hyperdrive/stack_profile.rb`) parses `Gemfile.lock` into a stack snapshot: Rails/Ruby/database facts plus `direct_dependencies` — the lockfile's `DEPENDENCIES` section with resolved versions, deliberately excluding transitive gems (a resolved-but-transitive gem like `minitest` is not part of the app's chosen stack). Its only consumers are the **pull surfaces** — the `describe_app` MCP tool and the `hyperdrive://stack-profile` resource — which answer "what is this app's stack" live, against the resolved bundle. The install side does not read it. Its `gem_skills_info` defers to `BundlerArtifactDiscovery` (below) and describes each installed skill as `name`, `gem` (the matched target gems, `["*"]` when universal), `source`, `version`, `path`, and `sha256`.
### Companion-gem artifact discovery contract
`BundlerArtifactDiscovery` (`lib/rails/hyperdrive/bundler_artifact_discovery.rb`) walks `Bundler.load.specs` for the four kinds the registry declares:
- **Skills** — `<gem-source>/skills/<name>/SKILL.md` (top-level, the recommended tool-agnostic location) and `<gem-source>/lib/<gem_name>/hyperdrive/skills/**/SKILL.md` (legacy, scanned forever for back-compat); dir-per-skill; `SKILL.md.erb` defines a skill the same way, rendered before frontmatter parsing — see gem-conditional content below. Also honors the manifest's top-level `skills_dir:` (union of convention path + top-level `skills/` + override, deduped by expanded path; a non-string or `..`-containing value warns and falls back to the defaults, a blank one falls back silently). A skill dir may ship **supporting files** — everything besides `SKILL.md`/`SKILL.md.erb`, at any depth. Discovery captures them on the `Artifact` as `support_files` (dir-relative path + raw bytes via `binread`; files named `SKILL.md`/`SKILL.md.erb` excluded at every depth, `..`-containing relative paths rejected, always empty for guidelines, excluded from `to_h` like `body`). They carry no frontmatter contract — `SKILL.md` frontmatter is the sole schema surface — and install byte-identical to the **install-ready body** (the shipped bytes; for `*.md.erb`, the rendered output) under `.claude/skills/<final_name>/<relpath>`, preserving layout. Collision postfixing renames the whole directory and rewrites only SKILL.md's `name:`, so dir-relative links inside a skill keep working with no body rewriting.
- **Guidelines** — `<gem-source>/lib/<gem_name>/hyperdrive/guidelines/<name>.md` (flat file, convention path only).
- **Agents** — `<gem-source>/agents/<name>.md`, plus the manifest's `agents_dir:`. Flat file, `name`+`description` required, identity is the frontmatter `name`, body installed verbatim (frontmatter kept), dest `.claude/agents/<final_name>.md`; collision postfixing renames the file *and* rewrites `name:`.
- **Commands** — `<gem-source>/commands/<name>.md`, plus the manifest's `commands_dir:`. Flat file, frontmatter **optional and never validated** (`description` is nil), identity is the filename stem run through the manifest's `command_prefix:`, body installed byte-verbatim, dest `.claude/commands/<final_name>.md`; collision postfixing renames the file only.
The flat kinds share one scan path parameterized by descriptor: convention roots ∪ the manifest dir override, deduped by expanded path, top level only (no subdirectories, no supporting files).
**ERB-templated flat artifacts.** Every kind carries an `erb` axis in the registry, true for all four content kinds, and the flat scan globs `*.md` + `*.md.erb` for them. A `<name>.md.erb` sits directly in the kind's shipped root — there is no template/content pairing for flat kinds and no public-root warning: Claude Code plugins glob `*.md`, so a template is invisible to them, and agents/commands are not skills.sh content. It renders at discovery through the same `SkillTemplate` binding skills use, before frontmatter parsing (a guideline is rendered *then* frontmatter-stripped at install), and a render failure skips the artifact as a hard skip — reported as the version fence instead when the entry is fenced out. Identity comes from the **rendered face**: `commands/foo.md.erb` is stem `foo`, prefixed like any other command. Tie rule: a static file beats a template rendering to the same filename **anywhere in the kind's root set** (flat identity is the filename), with a skip warning, never silently. Manifest join keys accept either spelling — shipped `foo.md.erb` or the `foo.md` face — resolved against `GemManifest#section_keys` before the gate is read, with a warning and the shipped spelling winning when both appear; both spellings count as shipped for the staleness warning, tie-dropped templates included. A convention-path guideline template is an opt-in signal like its static twin; bare `agents/`/`commands/` dirs remain non-signals. The lock's ancestor relpath is `.erb`-normalized, so `AncestorLocator`'s twin fallback reconstructs a merge ancestor for flat templates too, and `source_sha` over rendered bytes makes a re-render an ordinary upstream delivery. A companion shipping ERB flat artifacts should declare a **gem-wide** `hyperdrive_version: ">= 0.8"`: a pre-0.8 installer never globs the `.erb` source at all, so only a gem-wide fence — read while it discovers that gem's other artifacts — surfaces "upgrade rails-hyperdrive" instead of silence.
**Gem-conditional skill content** — both mechanisms run at **discovery time** inside `BundlerArtifactDiscovery` (the sole holder of the resolved bundle map), so an `Artifact` leaves discovery fully conditioned and downstream (`InstallPlan`, pipeline, status, auto-install) is untouched:
- *Per-file gating*: a `conditional:` map inside the manifest's per-skill entry (below) — keys are dir-relative **shipped** supporting-file paths, values take the artifact-level `gem:`/`gems:` forms verbatim (any-match by default; member-level requirements; `"*"` universal), read through the same `GemManifest.gem_key`/`parse_targets` helpers rather than a parallel implementation. A template-backed file may be keyed by either spelling — its shipped `x.md.erb` name or the `x.md` face it renders to — both resolving to the same gate through `target_path`; a manifest supplying both for one file warns and the shipped `.erb` spelling wins. Unlisted files install unconditionally. Malformed entries (non-map value, missing/unusable `gem:`) **fail open**: warn + install the file; a `versions:` key warns and is ignored like anywhere else. A key matching neither spelling of any shipped file, or naming `SKILL.md`(.erb), warns and is ignored.
- *ERB templates* (`SkillTemplate`, `lib/rails/hyperdrive/skill_template.rb`): `*.md.erb` supporting files render with a sealed binding of exactly `gem?(name, requirement = nil)`, `any_gem?(*names)`, `all_gems?(*names)`, `gem_version(name)` (String or nil) over the resolved bundle plus `canonical_render?` (`false` here, `true` in the canonical binding — the one helper deterministic in both, so a template may branch on it either way), `trim_mode: "-"`, and retarget to `x.md` in `support_files`. Gated-out templates are never rendered. Render failure → warn + skip that file; a failing `SKILL.md.erb` skips the whole skill. Tie rules: `SKILL.md` beats `SKILL.md.erb` in one dir, and a plain `x.md` beats an `x.md.erb` rendering to the same path — always with a warning, never a silent tiebreak.
**Template/content pairing** — lets one companion repo serve npx/git-clone consumers (who need a static `SKILL.md` with its supporting files in one dir) and hyperdrive (which wants the `SKILL.md.erb` master) at once. A skill dir `D` holding a static `SKILL.md` (found under either skills root, relative path `R` from that root) pairs with `<templates_root>/R/SKILL.md.erb` when that dir exists and is not `D` itself; `<templates_root>` comes from the manifest's top-level `skill_templates_dir:` (default `lib/<gem_name>/hyperdrive/skills`; unusable values fall back to it on the same terms as `skills_dir:`). The pair is **one artifact**: definition = the rendered template (`Artifact#path` points at it), supporting files = the content dir `D` (`Artifact#support_root`, which for every unpaired artifact equals `dirname(path)` and is excluded from `to_h` like `body`). The static `SKILL.md` is never parsed — not even as a fallback when the template fails to render (that would silently un-condition the skill; the artifact is skipped with a warning as any failing `SKILL.md.erb`). A consumed template dir is not also a standalone skill. Supporting `*.md.erb` files in it are gated, rendered, and installed, and each **owns its rendered target path** — a same-named content-dir file never installs, whether the template renders, is gated out, or fails to render; only non-ERB extras in the template dir warn and are ignored (static supporting files are the content dir's to ship). No pair → no change: template-only and content-only dirs are standalone skills exactly as before, and the same-dir tie rule is untouched. Lock relpaths split accordingly: the skill entry's ancestor relpath stays definition-side (`.md.erb → .md` normalized, resolved by `AncestorLocator`'s `.erb`-twin fallback), support entries are content-side (relative to `support_root`).
**Canonical render (companion-repo dev tooling)** — a companion's `Rakefile` adds `require "hyperdrive/skill_tasks"` — the sole require path, framework-neutral because the tasks run in a plain gem repo (`lib/hyperdrive/skill_tasks.rb`; the task bodies still call into `Rails::Hyperdrive::` modules) — to get `rake hyperdrive:skills:render` (write each template's static face to `<skills_root>/<R>/SKILL.md`) and `rake hyperdrive:skills:check` (byte-compare, fail listing stale paths — the CI freshness gate). Core module `CanonicalSkillRender` (`lib/rails/hyperdrive/canonical_skill_render.rb`, Rails-free, runs in a plain gem repo) resolves roots once per task from the manifest of the single `*.gemspec` in the cwd (optional explicit-path task argument; zero/multiple gemspecs error) — content root defaults to top-level `skills/` and templates root to the convention path, matching discovery, so a companion declaring neither manifest key still renders — and renders with `SkillTemplate.render_canonical`: fail-open (`gem?`/`any_gem?`/`all_gems?` → `true` even with a requirement, `gem_version` → `nil` — templates must handle nil, `canonical_render?` → `true`). The rendered face is written **verbatim** — nothing is stripped, matching install (gating lives in the manifest, not the content) — and template-using companions require the rails-hyperdrive release that ships pairing. Unlike discovery, this surface raises: a manifest that will not parse, an unusable `skills_dir:`/`skill_templates_dir:`, same-root (content dir == template dir), unrenderable template, or output without parseable `name`/`description` frontmatter (any YAML failure, not only a syntax error) are hard errors.
- Lock `source_sha` is over the rendered bytes, so the drift machine applies unchanged; a bundle change that alters render output or gates a file out is picked up by `init`/`sync` (gated-out unedited files ride the existing stale-support delete). `AutoInstall` (`:additive`) only tops up newly gated-in files — removals and re-rendered content wait for the next `sync`.
Both carry YAML frontmatter with exactly the skills.sh base contract — `name` and `description`, both required. Frontmatter is the whole content-side schema: a pure skills.sh SKILL.md parses with zero warnings, and any other keys (including a legacy `gem:`/`versions:`/`conditional:`) are unknown keys the permissive parser silently ignores and installs verbatim. The parser skips with a warning (collected, printed to stdout), never raising, on missing `name`/`description` or malformed YAML.
**Gem-root manifest (`GemManifest`, `lib/rails/hyperdrive/gem_manifest.rb`) — everything the installer reads from a companion.** It lives at `<gem-root>/hyperdrive.yml` (or the path named by a `hyperdrive_manifest` gemspec-metadata key; `..` segments or a blank value silently fall back to the conventional path), and gating never lives in content. Schema, all keys optional: top-level `skills_dir:`/`skill_templates_dir:`/`agents_dir:`/`commands_dir:` name the roots (above; `GemManifest.read_dir` is the single validator, fail-open here and raising in `CanonicalSkillRender`; the per-kind ones come straight off the registry's `dir_key`); top-level `gem:`/`hyperdrive_version:` are **gem-wide defaults**; one gating section per kind, built from the registry's `section` name — `skills:` entries are keyed by the skill dir's relpath from its skills root (the same join key template/content pairing uses — never the skill's `name:`), `guidelines:`/`agents:`/`commands:` entries by filename (`jobs.md`, and for commands the filename **as shipped**, before any prefix). **`gems:` is an exact alias of `gem:`** at every read position (defaults, entries, `conditional:` entries) and for every value shape; `GemManifest.gem_key` is the single extraction helper every read site goes through, and a map carrying both keys warns and reads `gems:` — deterministic, never malformed-and-widened. Resolution: the effective gate = the entry's `gem:`/`gems:` if either key is present, else the top-level default, else `"*"` (ungated). The entry's value replaces the default **wholesale** — requirements now travel with the targets, so there is no per-axis inheritance; `hyperdrive_version:` still resolves per key (entry's value if the key is present, else the default, else nil). A per-entry `gem: "*"` un-gates against a default, a per-entry `hyperdrive_version: ">= 0"` un-fences against one. `conditional:` exists only inside `skills:` entries (see per-file gating above) — the registry's `dir_shaped?` is what decides that. **`command_prefix:`** is a reserved section-level scalar the `commands:` kind declares via `prefix_key`: read as a setting rather than a gating entry (so it never draws a staleness warning), validated fail-open — a non-string, or a value carrying a path separator or `..`, warns and is ignored — and applied at **discovery**, where `<prefix>-<stem>` becomes the command's identity everywhere downstream (dest, collisions, `disabled:`, lock, status, printed surfaces) while the manifest's own keys stay the shipped filename. **`versions:` is removed** everywhere it was valid (top level, entries, `conditional:` entries): the key present → warn and proceed **unconstrained** (the `gem:` targets still gate; never a skip, and it is not the malformed path, so neither the entry nor the gem-wide defaults are dropped). **The version fence:** `hyperdrive_version:` is a `Gem::Requirement` (string, comma-separated string, or list; a map is malformed) checked at discovery time against the installer's own `Rails::Hyperdrive::VERSION`, never against the bundle — the sanctioned way to require a minimum installer, since multi-target `gem:` is any-match and cannot express "library AND installer ≥ X". It fences on the version of whichever gem implements discovery and install (today rails-hyperdrive), and that numbering is guaranteed continuous across any future restructuring of the gem, so a companion's `>= 0.8` keeps its meaning permanently. It is evaluated *before* target matching, so a fenced-out artifact reports only the fence: `skill 'x' (from gem) requires rails-hyperdrive >= 0.8 (this is 0.6.0); upgrade rails-hyperdrive to install it`. That line goes to the report's `skips` (the init/sync "discovery skipped" block) **and** to its separate `fence_warnings`, which `AutoInstall::Result#messages` appends verbatim so the bundler-plugin hook prints it during `bundle install`, alongside the report's advisories (ordinary skips stay out of that surface). A fenced-out `SKILL.md.erb` that cannot even render on this installer reports the fence rather than the render error, so the actionable half survives. A fenced-out artifact already on disk is **held**: the fence marks its source gem as having skipped something, so the stale-dest sweep leaves it alone and it is warned about as still bundled but not offered this run. Pre-fence releases ignore the key as an unknown key. **Target vs. source:** `gem:` names the *targets* (each must be present in the bundle; a target carrying a member-level requirement must also resolve to a satisfying version); `spec.name` during the walk is the *source* (provenance / conflict postfix). `gem: "*"` is universal (no target resolved). A well-formed gate matching nothing in the bundle → skip the artifact with a warning (that is gating working) and, because nothing is broken, **without** marking the source gem in `skipped_gems`, so the stale-dest sweep still converges for that gem. Failure modes never raise and never skip an artifact for unreadable gating: malformed/unreadable manifest or non-map root → warn, proceed as absent (an *empty* file is a valid manifest, no warning); non-map section → warn, section ignored; malformed entry → warn, the entry's own gate is ignored for that artifact, which then installs **ungated unless fenced out** — a non-map value or an unusable `gem:` still honors any resolvable `hyperdrive_version:`, and only an unparsable `hyperdrive_version:` installs ungated *and* unfenced (inheriting the gem-wide fence would impose a constraint that entry never asked for); unusable gem-wide defaults → one warning per axis, and the axes resolve independently, so an unusable default `gem:` never drops a parseable default fence. After each gem's scan, every section key matching no discovered candidate warns (the staleness signal for gating detached from content) — matching runs against pre-parse candidates, so a skill dropped later (e.g. ERB render failure) doesn't double-warn. `ManifestLint` (`lib/rails/hyperdrive/manifest_lint.rb`) is the author-side **strict** counterpart of that fail-open read, run as `rake hyperdrive:manifest:check` from `hyperdrive/skill_tasks`: it loads the manifest named by the single gemspec in the cwd (optional explicit-path task argument) and fails on unknown keys, unparsable gating, the retired `versions:`/`version:` keys, and entries naming nothing shipped, so a manifest that passes draws no gating warning at install time; a gem shipping no manifest is clean.
**Companion opt-in gate.** Discovery walks the whole bundle, and package contents don't signal consumer intent (many gemspecs use `git ls-files`, so contributor-facing `skills/` dirs ship by accident). A gem's artifacts are only scanned when it has opted in via any one signal: convention-path *skill or guideline* artifacts present, the `hyperdrive_targets` or `hyperdrive_manifest` metadata key, a `hyperdrive.yml` at the gem root, or the gem's name in the config's `enabled:` list. Bare `agents/`/`commands/` dirs are deliberately **not** signals — the names are too generic to read as companion intent — and they draw no notice either. A bundled gem that is *not* opted in but ships top-level `skills/**/SKILL.md` is **surfaced, never installed**: discovery appends one line per gem to a `notices:` collector (glob-only detection — no file reads; `SKILL.md.erb` excluded), which `InstallPipeline` prints in `init`/`sync` output (suppressed in `:additive`). `AutoInstall` honors the same gate — it passes the config's `enabled_gems` to discovery, so un-opted gems' skills never land via the bundler plugin hook, while `enabled:` gems install through the normal plan/pipeline on `bundle install` too. `StackProfile#gem_skills_info` passes the list as well.
**Multi-target `gem:` and member-level requirements.** A manifest `gem:`/`gems:` (default or per-entry, and inside `conditional:` entries) accepts a comma-separated string, a YAML list, or a **map with exactly one of `any:`/`all:`** — the recommended spelling for multi-target gates, whose values take those same flat forms. A bare map value is **mode-keys-only**: `gem: {railties: ">= 7.0"}` is malformed, never read as a name→requirement table, and the list is the sole container under a mode key. `GemManifest.parse_targets` is the single parser for every form; it returns a `TargetSpec` (targets + `match_mode` + the per-gem requirement map, plus a `warning` the caller contextualizes), the `Gate` carries `match_mode` and populates `versions` from that map, and `match_targets`/`no_match_reason` take it — so `conditional:` inherits every form through those same helpers, and `AutoInstall`/`StackProfile`, which only consume discovery's output, inherit it with no matching code of their own. **Version requirements ride on the members:** wherever a list is accepted, a member is a bare gem name (unconstrained) or a **single-pair map** `name: "requirement"`, valid in the bare list too (no mode key needed). The pair value is a `Gem::Requirement` passed whole to `requirement_parts` — never comma-split into targets, so `">= 4.9, < 6"` is one two-part requirement; `"*"` or nil as the value ≡ the bare member. Comma-splitting applies to bare **string** members only. `Gate#versions`/`Artifact#versions` keep their names and are now always nil or a map keyed by gem name. Bare forms mean `any:`: the artifact installs when at least one listed target is bundled at a satisfying version (a member requirement is a relevance floor — an unconstrained sibling can still carry the match), and `target_gem` is an **array** of every target that matched (a single-target artifact reports a one-element array). Under `all:` every listed target must be bundled at a satisfying version, and `target_gem` is the full list. `"*"` anywhere in an `any:`/bare list short-circuits to universal; under `all:` it is **warn-and-dropped** (`all: [devise, "*"]` gates on `devise` alone, `all: ["*"]` resolves universal) — it must never wrongly skip. `"*"` as a pair **key** is meaningless and warn-and-dropped in every list context; a sole dropped `"*"` pair leaves the list empty → resolves universal with only the drop warning (the same un-gate-against-defaults effect an explicit `"*"` has), not the malformed arm. A malformed member (multi-pair map, nested array, nil, unparsable pair value) takes the ordinary fail-open path. The `all:` skip warning is AND-flavored — "required target gem(s) '…' not in bundle" plus the per-target "does not satisfy" lines, both kinds reported when both occur. A malformed map (both keys, neither, unknown key, unusable value) takes the existing fail-open path: warn + install ungated, gem-wide defaults dropped too. The target participates in neither dedup phase, so a set-valued target adds no installs. Per-artifact targets are expected to stay a subset of the gem's `hyperdrive_targets`; this is convention, not enforced anywhere. `hyperdrive_targets` itself stays a flat any-match list that cannot express `all:`, so an AND companion is suggested by `hyperdrive:discover` when any of its declared targets is present — a separate surface, never reconciled.
Dedup is **two-phase**. *Phase 1* (discovery) collapses same-name candidates **within one source gem** (the same skill under two skill roots, or a flat file in both the convention root and the manifest's dir override) to the **lexicographically greatest path** — top-level `skills/foo` beats the legacy `lib/<gem>/hyperdrive/skills/foo` — reporting each dropped path as a skip; `spec_version` is identical for every candidate of one gem and plays no part. Composite identity is `(name, source_gem, artifact_type)`. *Phase 2* (install, in `InstallPlan`) groups Phase-1 survivors across sources: one source → canonical path; multiple sources → install **all**, each postfixed `--<source_gem>` on the path (and, for the kinds whose identity is frontmatter-borne — skills and agents — on the display `name:`).
Installed files carry **no audit header** — provenance (`source`, `source_sha`, `installed_at`) lives solely in the git-tracked `.hyperdrive/lock.yml`. Every artifact installs byte-identical to its install-ready body, so an unedited file's raw-byte hash (`DriftVerdict.disk_sha`) reproduces the lock's `source_sha` exactly — the basis for drift detection.
### Lockfile + idempotency/drift
`LockFile` (`lib/rails/hyperdrive/lock_file.rb`) reads/writes the git-tracked `.hyperdrive/lock.yml` manifest: per-file `source`, canonical `source_sha` (hash of the install-ready body), `installed_at` (volatile, never compared), plus `claude_md.state` (omitted entirely while no import line is being managed; tear-down transitions `present` → omitted, while `removed-by-user` is sticky). It is **state only** — the user's choices live in the config file (below). Top-level keys the schema does not recognize survive a read/write round-trip (entries under `files:` are regenerated), so `LockFile#carry_document` is what lets a freshly-built lock replace one read from disk; `disabled:`/`enabled:` are the exception, explicitly deleted on write so a pre-config lock's copies do not ride along. A lock still carrying either key reports `legacy_settings?`, and `legacy_settings_message` is the one warning about it — printed once by `init`/`sync`, carried in `AutoInstall::Result#messages` for the bundler hook. They are never read and never migrated. The drift state machine (in `InstallPipeline`): file current (`disk_sha == lock == gem`) → leave untouched; gem upgraded, file unedited → rewrite; user-edited → **skip + warn in preserve mode** (`init`, `sync`; the warning names `--merge`, `--sidecar`, and `--overwrite`), **force-overwrite in overwrite mode** (`sync --overwrite`), **reconcile in sidecar/merge mode** (`sync --sidecar` / `sync --merge`, below); missing → reinstall; **stale dest** (the plan no longer claims the path, the source gem is still bundled and lost no artifact to a *hard* discovery skip this run — an ordinary gate miss does not count) → delete under the sha gate, sweep any `<dest>.new` sidecar under the same pristine/edited rule, prune emptied dirs, drop the entry; orphan (everything else unplanned — source gem gone, or bundled but *held* because it skipped something) → warn + leave. The orphan warning names which of the two it is; `ArtifactStatus`'s `:orphaned` entries do the same through the shared `LockFile.orphan_reason`, so `AutoInstall`'s printed lines never claim "no longer shipped" while the gem is bundled. Both facts come from the discovery `Report`'s `bundled_gems`/`skipped_gems`, threaded through as the `InstallPipeline`/`ArtifactStatus` `report:`/`bundled_gems:` inputs that default to empty — no report, no stale removal.
**The config file.** `ConfigFile` (`lib/rails/hyperdrive/config_file.rb`) reads the hand-owned `.hyperdrive/config.yml` (`InstallLayout::CONFIG_PATH`), which holds the two lists the lock used to: `disabled:` (a per-kind map — `skills:`, `guidelines:`, `agents:`, `commands:` — of artifacts the user never wants installed) and `enabled:` (a flat list of gem names the app opts in as companions, opting those gems in wholesale, so every skill root of theirs is scanned — `disabled:` still beats `enabled:` per artifact, since `enabled:` only widens discovery while `InstallPlan.build` filters against `disabled:`). A third key, `resolve:`, holds the `--resolve` settings: `command:` (a non-empty string; anything else warns and reads as absent) and `prompt:` (an app-relative path, refused with a warning when it is absolute or carries a `..`). A non-map `resolve:` warns and drops both. The installer **only ever reads it**; `hyperdrive:init`'s `bootstrap_config` step creates it from `CONFIG_TEMPLATE` when absent (skipped by `--skip-content`, `--dry-run` covered by Thor's `pretend`) and every other run, `sync` and `AutoInstall` included, leaves it byte-untouched. It carries no version key. Reads are fail-open like `GemManifest` and never raise (`AutoInstall` reads `enabled:` from a `bundle install` hook): missing or empty file → empty config, no warning; unparsable, or a root that is not a map → one warning, empty config; a non-map `disabled:`, a non-list kind under it, or a non-list `enabled:` → one warning naming the part, that part ignored, the rest read. Unknown top-level keys and unknown kinds under `disabled:` are **silently** ignored, so later settings land without a parser change. One instance is loaded per run and threaded through the `config:` keyword on `InstallPipeline`/`ArtifactStatus`/`InstallPlan.build`, so its warnings print exactly once — via the pipeline in every mode but `:additive`, which reports them through `AutoInstall::Result#config_warnings` instead.
**The schema read guard.** `SCHEMA_VERSION` is written *and* read: `LockFile#schema_ahead?` is true when the parsed `version` is numeric and greater (missing or non-numeric fails open — every lock ever written carries an integer). A schema-ahead lock holds state this installer cannot read, and rewriting it would drop the user's `claude_md.state` silently, so `SyncRunner#install` raises a `Thor::Error` naming the upgrade before any content write (`--dry-run` included; init's lock-independent bootstrap steps still run), `InstallPipeline#call` returns an empty `Result` after one warning in **every** mode as a backstop, and `AutoInstall` — which must never raise — returns a normal `Result` carrying the reason in `halted`, which `messages` surfaces to the bundler-plugin hook. A guard shipped in one release cannot be added to installers already released, so it only pays off at the *next* bump: installers that carry it halt, older ones degrade. The skew itself needs `.hyperdrive/lock.yml` and `Gemfile.lock` to diverge in git (a reverted gem bump whose sync commit stays, a partial merge or cherry-pick) — switching branches alone moves both files together. `SCHEMA_VERSION` is **3**: at 2 an installer predating the agent/command kinds never discovers them and degrades rather than destroys — the stale-dest sweep ignores lock kinds it does not register, so their files are orphan-warned on every run and their entries carried, but the lock is written back down to the older version; at 3 `disabled:`/`enabled:` have left the lock, so an installer at 2 would read the absent lists as empty and reinstall every artifact the user opted out of. The guard makes an installer that carries it halt instead. **The unreadable-lock contract** is the same shape at the same three surfaces: `LockFile#read` raises `LockFile::UnreadableError` when the file cannot be opened (`SystemCallError`), does not parse (`Psych::Exception` — a syntax error, a `!ruby/object` tag, an alias), or parses to a root that is not a map, and every caller halts before any content write — `SyncRunner#install` with a `Thor::Error`, `InstallPipeline#call` with one warning and an empty `Result` in every mode, `AutoInstall` with the reason in `halted`. Treating any of those as an absent lock would rewrite it with every entry and `claude_md.state` dropped. An **empty** file still reads as absent with no message: a lock that has yet to be written is not a broken one.
- **Reconcile modes + sidecar contract.** `:sidecar` and `:merge` differ from preserve only on the `:edited` branch, and only when the bundle offers something the lock hasn't recorded as delivered (`gem_sha != lock.source_sha`; a no-lock-entry hand-written file always qualifies — "adoption"). `:sidecar` writes the full install-ready body — byte-identical to a live install — to `<dest>.new` (`InstallLayout.sidecar_path`), leaving the live file untouched. `:merge` first attempts a git three-way merge (ours = live body, base = reconstructed ancestor, theirs = new install-ready body) and writes a **clean** result to the live file; every failure rung — ancestor unavailable, binary content, no `git`, conflicted — degrades to the sidecar with the reason in the status line, so conflict markers never reach a live file. "Clean" is `git merge-file`'s textual judgment, not a verification, so `SyncRunner#summary_lines` prints a run footer pointing at `git diff` as the review surface whenever `Result#merged` is non-empty (a run that degraded to a sidecar reports no merge and gets no footer). Both re-lock **delivered-upstream** semantics: the dest's lock entry `source`/`source_sha` always record the newest upstream *delivered* (installed live, merged in, or sidecar-written), never the live file's own hash — a merged or hand-reconciled file truthfully reads `:edited` later but the same upstream is never re-offered, a sidecar is self-verifying by hash against the lock (`mv <dest>.new <dest>` = accept upstream, then reads `:current`), and `ArtifactStatus`/`AutoInstall` count a delivered-but-unresolved file as `installed`. While a sidecar is pending the entry also carries the **ancestor** — the upstream the live file's own edits descend from — as three optional keys, `ancestor_source` (`gem@version`, split/joined like `source`), `ancestor_sha`, and `ancestor_relpath` (the shipped file's gem-relative path, `.md.erb → .md` normalized), serialized only when present so every other entry is byte-identical to before. They are **written** on a fresh delivery over an entry-backed edited file, always from that entry's own `source`/`source_sha` and never from keys a closed window left behind; **carried unchanged** through a refresh, since the ancestor does not move because another upstream was delivered on top; and **cleared** by a pass before the lock is written that strips them from any entry whose `<dest>.new` is gone. Resolution therefore stays lock-free — deleting the sidecar is still the only signal — and the keys, only ever read while a sidecar is pending, are inert in the window between. Adoption records none. `:additive` carries them verbatim and neither writes nor clears them. Sidecars get no lock entry and are never referenced by `index.md`. An existing sidecar that is **machine-pristine** (its raw hash equals the old lock sha or the current gem sha) is refreshed on a newer delivery, and in `:merge` mode is first retried as a three-way merge over the recorded ancestor — including when no newer upstream is offered, since merging a pending delivery completes the offer rather than repeating it; a clean result writes the live file and sweeps the sidecar, and a failed equal-sha retry keeps the skip path with the reason appended to the unresolved-sidecar warning rather than re-delivering identical content. An edited sidecar is warned about, left alone, and blocks the lock bump so the upstream is re-offered once cleared. Whenever a non-additive run writes or verifies the live file, a leftover sidecar is swept — pristine → removed, edited → warned and left. `AncestorLocator` (`ancestor_locator.rb`) is the only reader of old gem versions: it scans `Gem.path` entries for `gems/<source_gem>-<locked_version>/<relpath>` (relpath from the `Artifact`'s `source_root`; `.md.erb` twin rendered with the current resolved bundle when the plain file is absent), rebuilds the install-ready form (guideline frontmatter strip, collision `name:` rewrite; skill bodies verbatim), and **sha-gates** it against the lock — any mismatch or exception reads as unavailable, never raises. Its `locate_recorded_ancestor` entry point runs the same lookup off an entry's ancestor keys, deriving `final_name` from the dest, for callers with no discovered artifact to read a relpath from. `ThreeWayMerge` (`three_way_merge.rb`) is the only invoker of `git merge-file` (via system tmpdir tempfiles, so `--dry-run` writes nothing into the app); missing git and binary input read as unavailable, fail-open like the `git check-ignore` call. Three opt-out state machines, all persistent and "never re-add": the single `@.claude/hyperdrive/index.md` line in `CLAUDE.md` (`present | removed-by-user`), per-guideline opt-out by deleting its `@`-line from `index.md`, and the config's `disabled:` list naming skills/guidelines the user never wants installed. `InstallPlan.build` filters the plan against that list; a listed artifact already on disk is deleted **only** when its body still hashes to the recorded `source_sha` — otherwise it is reported and left, keeping the installer's never-delete-user-work property. A **postfixed** name in the list (`foo--gem_a`) opts that one source's artifact out whether or not the collision that produced the postfix still exists — `build` always checks the per-source postfixed name, and `disabled_dest?` takes an optional `source_gem:` so `remove_disabled` and `ArtifactStatus` match a canonically-installed file against it. Disabling a skill removes its lock-recorded supporting files under the same gate; skill directories (and emptied subdirectories) go only once empty, so user-created files not in the lock always survive and keep the directory alive. A disabled dest is **never** reported as an orphan in any mode — the gem still ships it, so it is not stranded — and its lock entry is carried; the `disabled but locally modified` line is the one report. `:additive` never removes.
+ **Reconcile modes + sidecar contract.** `:sidecar` and `:merge` differ from preserve only on the `:edited` branch: they deliver when the bundle offers something the lock hasn't recorded as delivered (`gem_sha != lock.source_sha`; a no-lock-entry hand-written file always qualifies — "adoption"), and `:merge` also retries a pending pristine sidecar whose upstream the lock already records (below). `:sidecar` writes the full install-ready body — byte-identical to a live install — to `<dest>.new` (`InstallLayout.sidecar_path`), leaving the live file untouched. `:merge` first attempts a git three-way merge (ours = live body, base = reconstructed ancestor, theirs = new install-ready body) and writes a **clean** result to the live file; every failure rung — ancestor unavailable, binary content, no `git`, conflicted — degrades to the sidecar with the reason in the status line, so conflict markers never reach a live file. "Clean" is `git merge-file`'s textual judgment, not a verification, so `SyncRunner#summary_lines` prints a run footer pointing at `git diff` as the review surface whenever `Result#merged` is non-empty (a run that degraded to a sidecar reports no merge and gets no footer). Both re-lock **delivered-upstream** semantics: the dest's lock entry `source`/`source_sha` always record the newest upstream *delivered* (installed live, merged in, or sidecar-written), never the live file's own hash — a merged or hand-reconciled file truthfully reads `:edited` later but the same upstream is never re-offered, a sidecar is self-verifying by hash against the lock (`mv <dest>.new <dest>` = accept upstream, then reads `:current`), and `ArtifactStatus`/`AutoInstall` count a delivered-but-unresolved file as `installed`. While a sidecar is pending the entry also carries the **ancestor** — the upstream the live file's own edits descend from — as three optional keys, `ancestor_source` (`gem@version`, split/joined like `source`), `ancestor_sha`, and `ancestor_relpath` (the shipped file's gem-relative path, `.md.erb → .md` normalized), serialized only when present so every other entry is byte-identical to before. They are **written** on a fresh delivery over an entry-backed edited file, always from that entry's own `source`/`source_sha` and never from keys a closed window left behind; **carried unchanged** through a refresh, since the ancestor does not move because another upstream was delivered on top; and **cleared** by a pass before the lock is written that strips them from any entry whose `<dest>.new` is gone. Resolution therefore stays lock-free — deleting the sidecar is still the only signal — and the keys, only ever read while a sidecar is pending, are inert in the window between. Adoption records none. `:additive` carries them verbatim and neither writes nor clears them. Sidecars get no lock entry and are never referenced by `index.md`. An existing sidecar that is **machine-pristine** (its raw hash equals the old lock sha or the current gem sha) is refreshed on a newer delivery, and in `:merge` mode is first retried as a three-way merge over the recorded ancestor — including when no newer upstream is offered, since merging a pending delivery completes the offer rather than repeating it; a clean result writes the live file and sweeps the sidecar, and a failed equal-sha retry keeps the skip path with the reason appended to the unresolved-sidecar warning rather than re-delivering identical content. An edited sidecar is warned about, left alone, and blocks the lock bump so the upstream is re-offered once cleared. Whenever a non-additive run writes or verifies the live file, a leftover sidecar is swept — pristine → removed, edited → warned and left. `AncestorLocator` (`ancestor_locator.rb`) is the only reader of old gem versions: it scans `Gem.path` entries for `gems/<source_gem>-<locked_version>/<relpath>` (relpath from the `Artifact`'s `source_root`; `.md.erb` twin rendered with the current resolved bundle when the plain file is absent), rebuilds the install-ready form (guideline frontmatter strip, collision `name:` rewrite; skill bodies verbatim), and **sha-gates** it against the lock — any mismatch or exception reads as unavailable, never raises. Its `locate_recorded_ancestor` entry point runs the same lookup off an entry's ancestor keys, deriving `final_name` from the dest, for callers with no discovered artifact to read a relpath from. `ThreeWayMerge` (`three_way_merge.rb`) is the only invoker of `git merge-file` (via system tmpdir tempfiles, so `--dry-run` writes nothing into the app); missing git and binary input read as unavailable, fail-open like the `git check-ignore` call. Three opt-out state machines, all persistent and "never re-add": the single `@.claude/hyperdrive/index.md` line in `CLAUDE.md` (`present | removed-by-user`), per-guideline opt-out by deleting its `@`-line from `index.md`, and the config's `disabled:` list naming skills/guidelines the user never wants installed. `InstallPlan.build` filters the plan against that list; a listed artifact already on disk is deleted **only** when its body still hashes to the recorded `source_sha` — otherwise it is reported and left, keeping the installer's never-delete-user-work property. A **postfixed** name in the list (`foo--gem_a`) opts that one source's artifact out whether or not the collision that produced the postfix still exists — `build` always checks the per-source postfixed name, and `disabled_dest?` takes an optional `source_gem:` so `remove_disabled` and `ArtifactStatus` match a canonically-installed file against it. Disabling a skill removes its lock-recorded supporting files under the same gate; skill directories (and emptied subdirectories) go only once empty, so user-created files not in the lock always survive and keep the directory alive. A disabled dest is **never** reported as an orphan in any mode — the gem still ships it, so it is not stranded — and its lock entry is carried; the `disabled but locally modified` line is the one report. `:additive` never removes.
**`--resolve`.** `hyperdrive:sync --resolve` (sync only; it implies `--sidecar` delivery unless `--merge` is given, and `verify_options` rejects it alongside `--overwrite` as mutually exclusive) runs `SidecarResolver` (`sidecar_resolver.rb`) after the pipeline, handing each unresolved sidecar to the config's `resolve.command`. The gem ships no command, so `--resolve` with none configured raises before any content write, `--dry-run` included. Candidates are the lock's post-run entries whose `<dest>.new` is on disk, plus this run's `Result#sidecars` (a dry run wrote none to disk); a sidecar this run wrote is pristine by construction, an older one must still hash to the lock's `source_sha` or it is skipped as user work. The command is `Shellwords.split`, substituted **per token** so a path with spaces survives, and run through `Open3.capture3` with no shell and `chdir` the app root; `$LOCAL`/`$REMOTE`/`$BASE`/`$MERGED`/`$SOURCE`/`$PREVIOUS_SOURCE`/`$KIND`/`$PROMPT` are also exported as `HYPERDRIVE_*`. `$LOCAL` and `$MERGED` are the same path — the tool edits in place. **Exit 0 deletes the sidecar only when the command changed `$MERGED`** — git mergetool's `trustExitCode = false` default; exit 0 over an untouched or deleted live file, like every other exit and a missing executable, leaves the live file, the sidecar, and the lock untouched and reports the reason. Nothing here writes the lock: the sidecar's absence is already the resolution signal. `$BASE` is a tempfile in the system tmpdir holding the ancestor `InstallPipeline` reconstructed while writing the sidecar (`Result#sidecars` entries are `Sidecar` structs carrying `dest`, `ancestor`, `previous_source`; a fresh write locates one, a refresh rebuilds the recorded ancestor, and `:merge` reuses what its merge attempt found). A leftover sidecar has no struct, so `ancestor`/`previous_source` fall back to the entry's own ancestor keys — `$BASE` and `$PREVIOUS_SOURCE` survive every retry, orphaned entries included, as long as the ancestor's gem version is still on disk. Unavailable `$BASE` drops a bare `$BASE` token, substitutes empty inside a larger one, and unsets `HYPERDRIVE_BASE`. `$PROMPT` is rendered by `ResolvePrompt` (`resolve_prompt.rb`) from `resolve/prompt.md.erb` or the config's `resolve.prompt:` template, over a sealed binding of the other knobs as lower-case locals; an unreadable or unrenderable *user* template warns and falls back to the shipped one, while the shipped one failing is reported as that candidate's `unresolved` reason rather than being masked by a fallback. `AutoInstall` never resolves — `:additive` writes no sidecar, and a `bundle install` must not run a command named in a committed file.
Supporting files are ordinary lock entries under the `artifact:` kind **`skill_support`** (no schema change, per-file `source_sha` over raw bytes — never a tree hash), so the whole drift state machine applies per file. One extra delete path exists for them (same sha gate): when the bundle **stops shipping a supporting file while its owning skill is still planned**, an unedited copy is removed and emptied subdirectories pruned; an edited copy is warned about and its lock entry carried. Skipped in `:additive`; a skill whose whole source gem left the bundle stays on the ordinary orphan path (warn + leave, supporting files included). `ArtifactStatus` offers supporting-file dests too, so `AutoInstall` tops up a supporting file the lock does not record. The sync/init summary collapses `skill_support` entries into a `(+N files)` suffix on the owning skill's line; `installed_counts` counts skills, not files.
### Install pipeline
`InstallPipeline` (`lib/rails/hyperdrive/install_pipeline.rb`) owns all content installation: Phase-2 plan → skills/guidelines through the drift state machine → `index.md` → the one `CLAUDE.md` import line → the lock → warnings (two headers off one discovery `Report`: `skips`, the artifacts and files actually dropped, and `advisories`, everything that installed anyway) + eager footprint → a warning if git ignores an install destination (`.claude/skills`, `.claude/hyperdrive`, `.hyperdrive/lock.yml`, `.hyperdrive/config.yml`). That last check shells out to `git check-ignore` — `.gitignore` line-matching cannot see patterns, negations, or per-repo excludes — and treats any non-zero exit (no match, no repo, no git) as "nothing ignored". It takes an **explicit app root** and never reads `Rails.root`, so it runs in any process that can see the app's bundle. Five modes: `:preserve` (skip locally-modified files), `:overwrite` (force-overwrite them), `:sidecar` / `:merge` (reconcile them — see the reconcile-modes paragraph above; sync-only, `init` stays preserve), `:additive` (write only what the lock doesn't record *and* what isn't already on disk — it can create files but never overwrite one, never touches sidecars, and it leaves `CLAUDE.md` alone entirely; `index.md` is amended in place rather than recomputed, so user opt-outs and orphan lines survive; it reports no disabled artifacts and no config warnings, which are init/sync's to print). `Result` reports reconcile outcomes under `merged` and `sidecars`.
The **eager chain** — `index.md` plus the single `CLAUDE.md` import line — is companion-driven: it is written only when the plan holds at least one guideline, and torn down when it holds none. Tear-down deletes `index.md` and, for `CLAUDE.md`, deletes the file only when it is byte-identical to the one the installer wrote; otherwise it strips just the import line and leaves every other byte. A `removed-by-user` state survives tear-down, so a line the user deleted is still never re-added. An `index.md` that renders empty because the user deleted every `@`-line is kept, not torn down — it is the opt-out ledger, and deleting it would re-add everything on the next run. `:additive` never tears down.
It writes through a **shell** collaborator (`create_file` / `append_to_file` / `say_status` / `say`): the generator passes a `ThorShell` adapter so Thor's output and `--dry-run` keep working, everything else passes `InstallShell` (`install_shell.rb` — plain `FileUtils`, silent unless given an `io:`). `InstallPlan` (`install_plan.rb`) is Phase 2 extracted: it computes each artifact's final name, dest path, and install-ready body (including the postfixed skill's renamed `name:`), so the installer and the lockfile comparison hash exactly the same bytes.
`ArtifactStatus` (`artifact_status.rb`) compares what the bundle offers against `.hyperdrive/lock.yml` — `installed | missing | outdated | orphaned`. It is a **manifest** comparison, deliberately not a disk audit; disk state is the drift machine's job.
`AutoInstall` (`auto_install.rb`) is the entry point for callers with no Rails booted: it runs the comparison, installs only the missing artifacts (`:additive`), and returns everything it left alone for the caller to print. Because `:additive` never touches `CLAUDE.md`, a guideline landing in an app whose eager chain was never armed is inert until a `sync`; `Result#unwired` reports that (new guideline installed, lock `claude_md.state` still absent) so the caller can say so. Guards, in order: environment must read as development from `ENV` directly (`RAILS_ENV`/`RACK_ENV`), no `CI`, not a frozen bundle; and the app must already have a lock (it tops up an initialized app, it never bootstraps one). It never raises — a caller wrapped around `bundle install` must not turn a hyperdrive problem into a failed install.
### Generator
`lib/generators/hyperdrive/install/install_generator.rb` backs `bin/rails hyperdrive:init`; `lib/generators/hyperdrive/sync/sync_generator.rb` backs `bin/rails hyperdrive:sync` (both wired by `lib/rails/commands/hyperdrive/hyperdrive_command.rb`, a `Rails::Command` that forwards raw argv — one leading `--` stripped, so the legacy separator form still works — to the generators, which own all option parsing; the command mirrors each generator's `class_options` (minus Thor's inherited runtime flags) so `--help` cannot drift, and starts every generator with `destination_root: Rails.root`, so reads and writes share the app root no matter which directory `bin/rails` was invoked from. Both generators are non-interactive; shared plumbing lives in `lib/generators/hyperdrive/content_sync_support.rb`, the run sequence both generators' steps delegate to in `sync_runner.rb`, and the lock-derived summary formatting in `install_summary.rb`). Init's public flags: `--mount-at` (the mount path is a write-time argument only — it lands in the routes mount and the `.mcp.json` URL, and the gem exposes no configuration object to read it back; because it is interpolated into `config/routes.rb` as Ruby source, the `verify_options` step normalizes it and then requires a plain `/segment[/segment]` path of letters, digits, `_` and `-`, raising a `Thor::Error` before any write otherwise — `/` and the empty string included — and is skipped under `--skip-mcp`, which never uses the value), `--skip-content` (skips the *whole* `sync_content` step — skills, guidelines, `index.md`, the `CLAUDE.md` import, and the lockfile — and the `config.yml` bootstrap with it; leaves `.mcp.json`, the gitignore rule, the bundler-plugin Gemfile directive, and the mount), `--skip-mcp` (writes no `.mcp.json` entry and no engine mount), `--dry-run` (translated to Thor's `pretend`). Init's pipeline: verify env → verify options → discover artifacts → merge `.mcp.json` (unless `--skip-mcp`) → ignore the discover cache in `.gitignore` → register the bundler plugin in the `Gemfile` → mount engine (unless `--skip-mcp`) → bootstrap `.hyperdrive/config.yml` when absent → `sync_content` (hands off to `InstallPipeline` in `:preserve` mode) → summary. The mount step checks the `Rails.application.routes.draw do` anchor itself: no match (or no `config/routes.rb`) means a warning naming the line to add by hand and no write, and the summary then reads `Mount: <path> (not written to config/routes.rb; see warning above)` instead of `(in config/routes.rb)`. Sync's flags: `--overwrite`, `--merge`, `--sidecar` (each runs the pipeline in that mode instead of `:preserve`; `verify_options` raises a `Thor::Error` if more than one is given), `--resolve` (above), `--dry-run`; it runs the same content pipeline (verify env → verify resolver → discover → `sync_content` → `resolve_sidecars` → summary) and prints the same lock-derived summary, but writes no bootstrap artifact. The summary lists every entry of the lock the pipeline leaves behind (`InstallPipeline#lock`), grouped by `source` with rails-hyperdrive's own `internal@` group last, so it reports the app's resulting state rather than the run's writes. Skills install to `.claude/skills/<name>/SKILL.md` (frontmatter kept verbatim — the body is byte-identical to the shipped or rendered content); guidelines to `.claude/hyperdrive/guidelines/<name>.md` (frontmatter stripped, `@`-included via `index.md`); agents to `.claude/agents/<name>.md` and commands to `.claude/commands/<name>.md`, both verbatim and neither wired into anything — Claude Code registers them by file presence.
`.mcp.json` is the one artifact outside the lockfile drift machine. `write_mcp_json` reads any existing file, sets `mcpServers["rails-hyperdrive"]` (leaving every other server and sibling top-level key alone), and re-serializes with `JSON.pretty_generate`. Formatting survives by value, not byte-for-byte. The write is `create_file … force: true` so no run can stop on Thor's conflict prompt; idempotency comes from comparing the merged output against disk and skipping the write when equal. A `.mcp.json` that isn't a JSON object — or whose `mcpServers` isn't one — is warned about and left untouched, since its contents are unrecoverable once overwritten.
### Companion discovery
`CompanionDiscovery` (`lib/rails/hyperdrive/companion_discovery.rb`) backs `bin/rails hyperdrive:discover` (generator at `lib/generators/hyperdrive/discover/discover_generator.rb`, `--refresh` flag). This is the **only networked command** — read-only, never auto-run by `init`/`sync`, never modifies the Gemfile. It queries the rubygems search API with the field-scoped metadata query `metadata.hyperdrive_targets:*` (paginated 30/page until a short page), reads that key plus `hyperdrive_artifacts` **straight from the API response** (no `.gem` download), matches the declared targets against `Gemfile.lock` (`*` = universal), and prints `bundle add` suggestions. Companions are found by what they **declare**, not by name — the `rails-hyperdrive-` prefix is a recommended naming convention with no role in discovery. The metadata query is an **undocumented** passthrough to the rubygems search backend, so it is treated as best-effort: if it stops matching it returns an empty 200 page, which degrades to "no suggestions" rather than an error, and the live smoke canary is what turns that into a failing test. This pre-install `hyperdrive_targets` hint is a **separate surface** from the manifest `gem:` gating the installer uses authoritatively — it is never reconciled. Results cache to `.hyperdrive/discover_cache.json` (the one gitignored artifact; 24h TTL, `--refresh` busts). Offline / HTTP error / 429 → fall back to a stale cache (flagged) or report "unavailable" and exit cleanly; never raises. The HTTP fetcher is injectable (`fetcher:`) for tests. **Ships dormant** — empty until companions exist on rubygems.
### Bundler plugin gem (`bundler-hyperdrive/`)
A **second gem** lives in the `bundler-hyperdrive/` subdirectory, with its own gemspec and the `plugins.rb` Bundler requires when registering a plugin. The root gemspec remains the only gemspec at repo root, and its globs exclude the subdirectory from the rails-hyperdrive package. The plugin gem has **zero runtime dependencies** — deliberately not even rails-hyperdrive, because Bundler resolves plugins outside the app's `Gemfile.lock`; a gemspec dep would install a second rails-hyperdrive into the plugin's gem home instead of using the app's.
`plugins.rb` registers an `after-install-all` hook (once per `bundle install`, against the settled bundle — no per-gem hook). The hook (`Bundler::Hyperdrive.auto_install` in `bundler-hyperdrive/lib/bundler/hyperdrive.rb`): silent env guard (`RAILS_ENV`/`RACK_ENV` must read development, no non-empty `CI`) → resolve rails-hyperdrive from `Bundler.load.specs` → runtime range check (`>= 0.2`, deliberately uncapped) → put the resolved gem's `lib` on `$LOAD_PATH` and call `AutoInstall.run(root: Bundler.root.to_s)` — the entry point is the plugin's entire coupling to rails-hyperdrive — → print `result.messages` with a `[hyperdrive] ` prefix on non-indented lines. **Quiet failure is a hard contract**: the whole body is rescue-wrapped, every failure degrades to one printed line, and the hook must never fail a `bundle install`.
The plugin enters an app via the Gemfile directive `plugin "bundler-hyperdrive"`, written by `hyperdrive:init`'s `register_bundler_plugin` step (idempotent: any existing directive line — with `path:`, versions, whatever — counts as present; no Gemfile → skip status). Only `init` writes it, never `sync`. Known limits (checked against Bundler 2.6.9): `bundle lock` runs no hooks; `BUNDLE_PLUGINS=false` disables the plugin system entirely; the plugin's own version is not pinned by the app's `Gemfile.lock`.
## Test infrastructure
- **Combustion** (`spec/spec_helper.rb`) boots a real Rails app from `spec/internal/`. Schema is `spec/internal/db/schema.rb` (Users + Posts on SQLite).
- `ENV["RAILS_ENV"]` is forced to `"development"` in the spec helper because the engine middleware refuses anything else.
- `before(:each)` resets `StackProfile` and `McpServer` singletons — preserve this when adding new singletons.
- Generator specs write into `spec/tmp/install_generator/` (gitignored) and **stub `BundlerArtifactDiscovery.discover`** to inject `Artifact` structs (default: empty → zero-content install). Real artifact discovery is exercised against `spec/fixtures/dummy_gem/` and `spec/fixtures/companion_gem/` (the latter targets `dummy_gem` from a different source, covering the target/source split + cross-source collision).
- **Smoke specs** (`spec/smoke/`, tagged `:smoke`, excluded by default in `.rspec`) shell out to real `bin/rails` subprocesses against fixture apps under `spec/fixtures/smoke_apps/{minimal,services,full_stack}/` and POST JSON-RPC to a booted server. Run with `bundle exec rspec --tag smoke`.
- **`smoke_helper.rb`** owns everything a scenario shares. Each example's app and companion copies live under `spec/tmp/smoke/`; a config-level `around` (outermost, so a group's own `around` has already stopped its server) removes them when the example passed and keeps them for post-mortem when it failed. The resolved-gem cache at `spec/tmp/smoke-bundle/` is never touched, and `bundle_install!` retries once on a fetch failure that reads as transient so a throttled runner is not a red build. `boot_server!` picks a port, and retries up to three times when the child dies on `EADDRINUSE` — the bind-then-release window cannot be closed by handing `rails server` a socket. `mcp_call` is the happy-path wrapper over `mcp_post`, which returns `[status, body]` and takes an `origin:` override (nil sends no Origin header) so a 403 is assertable. `vendor_companion!` takes a per-app copy of a fixture companion, `copy_companion` an app-free one, and `write_plain_gem!` authors an un-opted gem inline; `gem_home`/`remove_from_gem_home!` own the shared bundle's gem-home paths.
- **Fixture companions** (`spec/fixtures/smoke_companions/`) are real path gems and stay byte-identical across the suite — every other shape is a tmp copy or the inline gem. `rails-hyperdrive-alpha` ships the template/content-paired `alpha-skill` (a `SKILL.md.erb` master plus a content dir whose supporting files include a `conditional:`-gated pair and an `*.md.erb`), the static `shared-skill`, a static and an ERB guideline, a static and an ERB agent, a static and an ERB command under `command_prefix: alpha`, and a manifest carrying `skills_dir:` and a gem-wide gate. `rails-hyperdrive-beta` ships only `shared-skill` (colliding by name with alpha's) and `beta-guide`.
- **`install_generator_spec`** covers the zero-companion install against all three apps — `.mcp.json`, the mount, and `.hyperdrive/lock.yml`, with no `CLAUDE.md`, no `index.md`, and no `.claude/skills` — plus `--dry-run`, a run from a subdirectory (writes land under the app root), the legacy `--` separator, and the three init flags (`--skip-content`, `--skip-mcp`, `--mount-at`). It does **not** assert the `.gitignore` rule; only `discover_generator_spec` does.
- **`companion_install_spec`** drives the install pipeline end-to-end: full companion install of all four kinds with `index.md` aggregation and footprint, `--overwrite` restore of a locally-modified file, the `--sidecar` delivery lifecycle against a mutable copy of alpha (live file byte-untouched, `.new` written and re-locked, `mv` accepts, next sync reads current), `--resolve` both when the command resolves the file and when it exits 0 without writing `$MERGED` (sidecar, live file, and lock all untouched, the reason reported), the `disabled:` round trip through `.hyperdrive/config.yml` including eager-chain tear-down, cross-source skill collision, the version fence (both artifacts skipped with the fence line, then held rather than deleted once installed and re-fenced), multi-target `any:`/`all:` gates with the AND-flavored miss, the `notices:` line for an un-opted gem and its install once `enabled:` names it, stale-dest removal and both orphan wordings, the schema-ahead halt (asserted through output and the absence of writes — a generator's `Thor::Error` still exits 0), and cross-source agent and command collisions.
- **`sync_merge_spec`** builds the alpha fixture at v1 into a `.gem` and `gem install --local --install-dir`s it into the shared bundle's gem home (the one gem home the sync subprocess's `Gem.path` resolves to under `BUNDLE_PATH`), removing it **before and after each example** so a crashed run cannot leak into the next. It covers a clean three-way merge over a non-overlapping edit, a second merge over the ancestor a pending sidecar recorded, the degrade to a sidecar when the edits overlap (live file left marker-free, the reason on the status line, no `Merged` footer), and a resolver reading `$BASE`/`$PREVIOUS_SOURCE` after an upgrade.
- **`mcp_server_spec`** boots the server against `full_stack` with alpha bundled, a model, a SQLite schema, and a seeded log file, and exercises all eight tools and both resource families — `resources/list`, `resources/templates/list`, reads of a skill and the stack profile, and the unknown-URI JSON-RPC error — plus both 403 paths: a non-allowlisted Origin against that server, and a `minimal` copy booted with `RAILS_ENV=production` (its mount is stripped of the `if Rails.env.development?` guard so the middleware is reachable).
- **`skill_tasks_spec`** runs `rake hyperdrive:skills:check` and `:render` in an app-free companion copy with no bundle at all, asserting `check` passes clean, fails on a stale canonical face, and passes again after `render`.
- **`auto_install_spec`** drives `AutoInstall` through `bundle exec ruby -e` in the fixture app — **no Rails booted**, which is the whole point of the extraction — asserting a newly bundled companion's artifacts land, a locally-edited file survives, and a non-development environment installs nothing. **`discover_generator_spec`** smokes the networked `hyperdrive:discover` against the live rubygems API and asserts the `.gitignore` rule: a healthy run must print a recognized outcome (silently-broken exits fail); offline/rate-limited runs are flagged and only the outcome assertion is skipped, keeping the suite deterministic.
- Every fixture registers the bundler-hyperdrive plugin from its in-repo path (`Smoke.add_path_gem!` appends both the `gem` line and the `plugin "bundler-hyperdrive", path:` line), so the hook running on every smoke `bundle install` is a standing regression check that it never breaks an install; **`bundler_plugin_spec`** covers the plugin end-to-end (a bundled companion's artifacts land during `bundle install` itself and the `[hyperdrive] installed` line proves the hook ran rather than quietly no-opping, an edited file survives, a version fence is printed, an upgraded companion is only reported — never overwritten, production installs nothing), and `auto_install_spec` passes `BUNDLE_PLUGINS=false` on its installs so it keeps driving the `AutoInstall.run` entry point directly with the plugin inert.
- CI smoke job triggers on every push to `main`, on `workflow_dispatch`, or on PRs with the `run-smoke` label, and runs two corner slots only (Ruby 3.2/Rails 7.2 and Ruby 3.4/Rails 8.1).
- **Known coverage limits** (verified manually, not by the suite): two paths can't be exercised today. (1) **`hyperdrive:discover` with a live, non-empty result** — no `rails-hyperdrive-*` companion gems are published to rubygems yet, so the networked discover smoke only ever sees an empty result set; the with-results and 24h-cache-reuse paths are covered at unit level via `CompanionDiscovery`'s injectable `fetcher:`. (2) **Claude Code runtime consumption** — `.mcp.json` autoload, `@`-import resolution of `index.md`, and lazy skill loading all happen inside Claude Code itself, outside any process this suite can drive.
## Gemfile & dependency notes
- `Gemfile.lock` is **gitignored** — Bundler resolves fresh each install. CI keys its cache off `RAILS_VERSION` to avoid cross-slot bleed.
- Runtime deps: `railties`, `activerecord` (both `>= 7.2`, no upper cap), `mcp ~> 0.25`, `bundler >= 2.3`.
- License must stay MIT throughout, including transitive runtime deps — no Apache-licensed runtime additions.