rails-hyperdrive 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7577ebf92af02fb8bb079a8b32c8751744340390f0f0facc0e682bc31aa6d43d
4
- data.tar.gz: 990f940533691baf570a17113806fcd274300b56ee171a22195921ea9ddeb6bd
3
+ metadata.gz: b3fbb6e0845e6194c6b33334e2bd03db6629cd61a8bfd973f8be3ce7b82fd1be
4
+ data.tar.gz: 8f259097744b8729ef71756df833809ff315305bf52a822ca1e40fa94298bc26
5
5
  SHA512:
6
- metadata.gz: af48e37db907f10bf2a74d8303aa57b5cd04bd44d7ab97834bc197d97f39cf77cef87116217ce8d773d95d5ae6d93b6e216cbec275b3886bdb7c9b32cbe356af
7
- data.tar.gz: 99144761cd64cf3bbed57caf0eaa0ab691511d7246e7add30e8fdf94bcd2f1e857c0b1612b5b95033a9336ca71faf80bd9bdae3db9813c3377dea3efdf6986c7
6
+ metadata.gz: 3986cc09cecd60a44ecc9899be526c3db981d635a8bc458de956965b227fe51a2d3d4efc8e6da03f8016fc83320d59235d975a2ccdb22d4e86d6d75d7296e970
7
+ data.tar.gz: 4524e73cd275471bc2e555ff6845b31b3e4b96f75839bd5629b59611522e85d3dd224c4cc5b197f9a38d1ecf66a6fa9961d9ea60567c9626bb90ff46cc2825de
data/CHANGELOG.md CHANGED
@@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-08-08
11
+
12
+ ### Added
13
+
14
+ - `hyperdrive:sync --sidecar`: when an installed file is locally modified and
15
+ its gem ships something new, the new upstream body is delivered next to it
16
+ as `<file>.new` instead of being skipped. The live
17
+ file is never touched; `mv <file>.new <file>` accepts the upstream
18
+ wholesale. The lockfile records the delivered upstream, so the same version
19
+ is offered exactly once, and a delivered-but-unresolved file no longer nags
20
+ from `bundle install`.
21
+ - `hyperdrive:sync --merge`: same as `--sidecar`, but first attempts a git
22
+ three-way merge of the local edits with the upstream change, using the
23
+ previously installed gem version (found in the installed gem directories,
24
+ content-verified against the lock) as the ancestor. Only a clean merge is
25
+ written to the live file — a conflict, a missing ancestor, a missing `git`,
26
+ binary content, or an earlier delivery still unresolved at `<file>.new` all
27
+ fall back to the sidecar delivery, so conflict markers never reach a live
28
+ file and a pending delivery is never merged over.
29
+ - Leftover sidecars are swept: whenever a sync writes or verifies the live
30
+ file, a `<file>.new` still matching a delivered upstream is removed, and
31
+ one you edited is warned about and left alone.
32
+
33
+ ### Changed
34
+
35
+ - **BREAKING:** the stack profile (`describe_app` MCP tool and the
36
+ `hyperdrive://stack-profile` resource) no longer categorizes gems into
37
+ `test` / `jobs` / `frontend` / `auth` / `authz` / `db_gems` buckets. Those
38
+ keys are replaced by `direct_dependencies` — the app's declared gems (the
39
+ lockfile's `DEPENDENCIES` section) with their resolved versions. The old
40
+ buckets matched a hand-curated gem list against the *resolved* dependency
41
+ set, so every Rails app reported transitive `minitest` as a chosen test
42
+ framework
43
+ ([#2](https://github.com/rails-hyperdrive/rails-hyperdrive/issues/2));
44
+ raw direct dependencies carry no such editorializing and
45
+ need no curated list to go stale. `rails`, `ruby`, `database`, and
46
+ `gem_skills` are unchanged.
47
+
48
+ - Installed skills no longer carry the installer-only frontmatter keys
49
+ (`gem:`, `versions:`, `conditional:`). They are discovery-time inputs with
50
+ no post-install reader, and the `conditional:` map referenced shipped paths
51
+ that gating and ERB rendering could leave pointing at files absent from
52
+ disk — dead weight in the agent's context window at every skill invocation.
53
+ The installed frontmatter now holds only what the runtime reads (`name:`,
54
+ `description:`, any extra keys like `allowed-tools:`). Because the
55
+ install-ready body changes, the next
56
+ `hyperdrive:sync` rewrites each unedited installed skill once; locally
57
+ edited skills are skipped with the usual warning.
58
+ - The skip warning for a locally-modified file now names all three
59
+ reconciliation flags (`--merge`, `--sidecar`, `--overwrite`).
60
+ - Orphan reports now say "no longer shipped by \<source\>" instead of
61
+ "source \<source\> no longer in bundle" — an artifact is also orphaned when
62
+ its gem is still bundled but stopped shipping it.
63
+
64
+ ### Removed
65
+
66
+ - Audit headers. Installed skills and guidelines no longer carry the
67
+ `# hyperdrive: source=...` / `<!-- hyperdrive: ... -->` comment block —
68
+ every installed file now lands byte-identical to its install-ready body
69
+ (sidecar `.new` deliveries and merge results included). The header
70
+ duplicated what the git-tracked `.hyperdrive/lock.yml` already records per
71
+ file (`source`, `source_sha`, `installed_at`) and was loaded into the
72
+ agent's context on every skill invocation (and eagerly, for guidelines).
73
+
10
74
  ## [0.3.0] - 2026-08-04
11
75
 
12
76
  ### Added
@@ -289,7 +353,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
289
353
  - `hyperdrive:init` generator that installs architecture skills and auto-discovers
290
354
  per-gem skills.
291
355
 
292
- [Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.3.0...HEAD
356
+ [Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.4.0...HEAD
357
+ [0.4.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.4.0
293
358
  [0.3.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.3.0
294
359
  [0.2.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.2.0
295
360
  [0.1.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,21 +1,25 @@
1
- # Rails Hyperdrive
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/rails-hyperdrive/rails-hyperdrive/main/docs/logo-wide.png" alt="" width="480">
3
+ </p>
2
4
 
3
- > Dev-only Rails engine that bootstraps an MCP server + skills/guidelines for AI coding agents (Claude Code first).
5
+ # Rails Hyperdrive
4
6
 
5
- Rails Hyperdrive mounts an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server at `http://localhost:3000/_hyperdrive/mcp` in development, exposing **8 introspection tools** so AI agents stop guessing — they can eval Ruby, query the DB (read-only), tail logs, list models and routes, locate source, fetch docs, and snapshot the stack.
7
+ **Live introspection and stack-matched knowledge for AI coding agents, straight from your Rails app.**
6
8
 
7
- It also ships a `hyperdrive:init` generator that discovers and installs **two artifact types** that companion gems ship under a documented contract:
9
+ [![Gem Version](https://img.shields.io/gem/v/rails-hyperdrive)](https://rubygems.org/gems/rails-hyperdrive)
10
+ [![CI](https://github.com/rails-hyperdrive/rails-hyperdrive/actions/workflows/ci.yml/badge.svg)](https://github.com/rails-hyperdrive/rails-hyperdrive/actions/workflows/ci.yml)
11
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
8
12
 
9
- - **Skills** lazy, model-invoked via Claude Code's native description matcher. Procedural ("how to write an idempotent Sidekiq job"). Installed to `.claude/skills/<name>/SKILL.md`.
10
- - **Guidelines** — eager, always in context via `@`-include from `CLAUDE.md`. Declarative ("this app uses Pundit, not CanCanCan"). Installed to `.claude/hyperdrive/guidelines/<name>.md`.
13
+ Rails Hyperdrive is a development-only Rails engine for working on Rails apps with AI coding agents. It gives the agent two things it can't get from source alone — live answers from the booted app, and guidance specific to the gems and versions in the bundle:
11
14
 
12
- **rails-hyperdrive is the mechanism; companion gems are the content.** rails-hyperdrive itself ships no skills or guidelines only the contract and the discovery/install engine. Content comes from companion gems, conventionally named `rails-hyperdrive-<library>` (e.g. `rails-hyperdrive-sidekiq`) following the [RuboCop ecosystem](https://github.com/rubocop/rubocop) precedent.
15
+ - **Live introspection.** The engine mounts an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server at `http://localhost:3000/_hyperdrive/mcp` with **8 tools** that answer from the running app itself: eval Ruby, query the DB (read-only), tail logs, list models and routes, jump to source, look up docs, snapshot the stack. The agent asks the router instead of grepping `routes.rb`, and reads the live schema instead of replaying migrations.
16
+ - **Stack-specific knowledge.** `bin/rails hyperdrive:init` discovers **skills** and **guidelines** shipped by companion gems and installs only the ones matching your Gemfile: guidance targeting Sidekiq, for example, lands only if your app bundles Sidekiq, at a version the guidance covers.
13
17
 
14
- Built on the official [`mcp` gem](https://github.com/modelcontextprotocol/ruby-sdk). MIT-licensed.
18
+ **rails-hyperdrive is the mechanism; companion gems are the content.** The gem itself ships no skills or guidelines — only the contract and the discovery/install engine. Content comes from companion gems, conventionally named `rails-hyperdrive-<library>` (e.g. `rails-hyperdrive-sidekiq`), following the [RuboCop ecosystem](https://github.com/rubocop/rubocop) precedent.
15
19
 
16
20
  ---
17
21
 
18
- ## Golden path
22
+ ## Quick start
19
23
 
20
24
  ```bash
21
25
  # 1. Add the dev gem
@@ -53,17 +57,13 @@ $ bin/dev
53
57
  # → agent has 8 tools, the eager guidelines (via CLAUDE.md), and the lazy skills
54
58
  ```
55
59
 
56
- `hyperdrive:init` also registers the `bundler-rails-hyperdrive` Bundler plugin in your Gemfile. From then on, `bundle add rails-hyperdrive-<library>` lands the companion's artifacts on that very `bundle install` — no explicit sync. The plugin is additive only (it never touches an existing file); version bumps and orphaned artifacts are only reported, with a pointer to `bin/rails hyperdrive:sync`.
57
-
58
- Run `bin/rails hyperdrive:sync` any time (e.g. after `bundle update` or adding a companion gem) to refresh installed content to the current bundle. It touches no bootstrap artifact and leaves locally-modified files untouched (skip + warn); pass `--overwrite` to restore them to the gem-shipped content.
59
-
60
- Run `hyperdrive:discover` to find companion gems published for your stack that you haven't installed yet — it queries rubygems (read-only, results cached for 24h; `--refresh` re-queries) and prints the `bundle add` lines to run, then run `bin/rails hyperdrive:sync`. It never touches your Gemfile or makes network calls unless you invoke it.
60
+ That's it. No API keys, no config files to write, no per-project setup beyond the generator.
61
61
 
62
62
  ---
63
63
 
64
- ## What ships
64
+ ## What your agent gets
65
65
 
66
- ### MCP tools (8)
66
+ ### 8 MCP tools
67
67
 
68
68
  | # | Tool | Purpose |
69
69
  |---|------|---------|
@@ -73,30 +73,66 @@ Run `hyperdrive:discover` to find companion gems published for your stack that y
73
73
  | 4 | `list_models` | List Active Record model classes with columns/validations/associations |
74
74
  | 5 | `locate_source` | Resolve `Const` / `Const#method` / `Const.method` / `dep:<gem>` to a file:line |
75
75
  | 6 | `lookup_doc` | Look up RDoc for a symbol (via `ri`) |
76
- | 7 | `describe_app` | Snapshot: Rails/Ruby/DB versions + full `StackProfile` |
76
+ | 7 | `describe_app` | Snapshot: Rails/Ruby/DB versions + direct gem dependencies |
77
77
  | 8 | `list_routes` | All routes: HTTP verb, path, controller#action, named route |
78
78
 
79
- ### Resources
79
+ Plus two MCP resources: `hyperdrive://stack-profile` (JSON snapshot of your resolved stack) and `hyperdrive://skills/{name}` (the markdown body of each installed skill).
80
+
81
+ ### Two kinds of knowledge
82
+
83
+ Companion gems ship two artifact types, tuned for how agents consume context:
84
+
85
+ - **Skills** — *lazy*. Loaded on demand via Claude Code's native description matcher. Procedural knowledge: "how to write an idempotent Sidekiq job". Installed to `.claude/skills/<name>/SKILL.md`, optionally with supporting files (references, examples, workflows) alongside.
86
+ - **Guidelines** — *eager*. Always in context via a single `@`-include from `CLAUDE.md`. Declarative facts: "this app uses ActionPolicy, not Pundit". Installed to `.claude/hyperdrive/guidelines/<name>.md`.
87
+
88
+ Every artifact declares which gem it targets and at which versions, so what installs is exactly what matches your `Gemfile.lock` — nothing generic, nothing stale.
89
+
90
+ With no companion gems, `hyperdrive:init` sets up just the server plumbing (`.mcp.json`, the engine mount, the lockfile) and puts **nothing** into your agent's context window. Zero context cost until you opt in.
91
+
92
+ ---
93
+
94
+ ## Staying in sync
95
+
96
+ **After `bundle install` — automatic.** `hyperdrive:init` registers the [`bundler-rails-hyperdrive`](bundler-rails-hyperdrive/) Bundler plugin in your Gemfile. From then on, `bundle add rails-hyperdrive-<library>` lands the companion's artifacts on that very `bundle install` — no extra command. The plugin is additive only (it never touches an existing file); version bumps and orphaned artifacts are only reported, with a pointer to `hyperdrive:sync`.
97
+
98
+ **`bin/rails hyperdrive:sync` — on demand.** Run it any time (e.g. after `bundle update`) to refresh installed content to the current bundle. It touches no bootstrap artifact and leaves locally-modified files untouched (skip + warn). When you *have* edited an installed file and its gem ships a new version, three mutually-exclusive flags reconcile the two:
99
+
100
+ | Strategy | What happens to the live file | What happens to your edits |
101
+ |---|---|---|
102
+ | `--merge` | Rewritten with a git three-way merge when it applies cleanly; otherwise untouched and the upstream lands as a `--sidecar` delivery | Kept — a merge that would need conflict markers falls back to the sidecar instead, so nothing half-merged ever goes live |
103
+ | `--sidecar` | Untouched; the new upstream body is written next to it as `<file>.new` | Kept, byte-for-byte |
104
+ | `--overwrite` | Restored to the gem-shipped content | Discarded |
105
+
106
+ A sidecar is inert — Claude Code loads only `SKILL.md` and the `index.md` `@`-lines, never a `.new` file — and it shows up in `git status` as your prompt to resolve. Resolve it by folding what you want into the live file and deleting the `.new`, or `mv <file>.new <file>` to accept the upstream wholesale. Either way the lockfile already records that delivery, so the next sync doesn't re-offer the same version (and a leftover sidecar you haven't touched is cleaned up once the live file catches up). `--merge` needs the previously installed gem version still present on disk to reconstruct the merge ancestor; when it isn't (CI, after `gem cleanup`), it degrades to the sidecar with a note saying why.
80
107
 
81
- - `hyperdrive://stack-profile` JSON of the resolved `StackProfile`
82
- - `hyperdrive://skills/{name}` — markdown body of each installed skill
108
+ The sidecar pair is also how an AI coding agent reconciles for you, with no extra machinery: run `bin/rails hyperdrive:sync --sidecar`, have the agent merge the live/`.new` pair semantically (it has both full texts), then delete the sidecar.
83
109
 
84
- ### Install layout
110
+ **`bin/rails hyperdrive:discover` — find what you're missing.** Queries rubygems for companion gems published for your stack that you haven't installed yet, and prints the `bundle add` lines to run. Read-only, results cached for 24h (`--refresh` re-queries), and it never touches your Gemfile or makes network calls unless you invoke it.
111
+
112
+ ---
113
+
114
+ ## You stay in charge
115
+
116
+ ### Everything lands git-tracked
85
117
 
86
118
  ```
87
119
  CLAUDE.md # user-owned; ONE injected line: @.claude/hyperdrive/index.md
88
120
  .claude/hyperdrive/
89
121
  index.md # managed aggregator: @guidelines/<name>.md
90
- guidelines/<name>.md # companion-shipped, frontmatter stripped, audit-headered
122
+ guidelines/<name>.md # companion-shipped, frontmatter stripped
91
123
  .claude/skills/<name>/
92
- SKILL.md # companion-shipped, frontmatter kept, audit-headered
124
+ SKILL.md # companion-shipped, frontmatter kept minus installer keys
93
125
  <supporting files> # optional companion-shipped extras, installed as shipped (*.md.erb rendered)
94
126
  .hyperdrive/lock.yml # git-tracked manifest (source gem, version, content hash)
95
127
  ```
96
128
 
97
- `CLAUDE.md` and `index.md` are the **eager chain** they exist only because a companion gem ships a guideline, and both go when the last one leaves the bundle (the guideline file itself is left on disk and reported as an orphan). With no companion gems installed, `hyperdrive:init` writes `.mcp.json`, the `.gitignore` rule, the optional initializer, the engine mount, and `.hyperdrive/lock.yml` and nothing into the agent's context window.
129
+ A `git diff` is where you review what a companion gem added. The install summary names each artifact's source gem and version, and every installed file is hashed and attributed to its source in the git-tracked `.hyperdrive/lock.yml` the files themselves land byte-identical to what the gem ships, with nothing injected. `hyperdrive:init` warns if your app gitignores these paths, since that empties the diff without changing what reaches the agent. The `hyperdrive:discover` cache is the one file rails-hyperdrive adds to `.gitignore`.
98
130
 
99
- Everything a companion gem contributes lands here git-tracked, so a diff is where you review what it added — the install summary names each artifact's source gem and version, and every SKILL.md and guideline carries the same provenance in an audit header. A skill's supporting files carry no header — they install byte-identical to the install-ready body (the shipped bytes; for `*.md.erb` templates, the rendered output), and their provenance and content hash live in `.hyperdrive/lock.yml` alone. `hyperdrive:init` warns if your app gitignores these paths, since that empties the diff without changing what reaches the agent. The `hyperdrive:discover` cache is the one file rails-hyperdrive adds to `.gitignore`.
131
+ `CLAUDE.md` and `index.md` are the **eager chain** — they exist only because a companion gem ships a guideline, and both go when the last one leaves the bundle (the guideline file itself is left on disk and reported as an orphan).
132
+
133
+ ### Your edits win
134
+
135
+ Installed files are yours to modify. The lockfile hash tells the installer whether a file is still gem-pristine: unedited files are refreshed on upgrade, edited files are skipped with a warning — never silently overwritten. `hyperdrive:sync --overwrite` is the explicit way back to gem-shipped content.
100
136
 
101
137
  ### Turning off a single artifact
102
138
 
@@ -114,28 +150,24 @@ A disabled artifact is never installed, and one already on disk is removed on th
114
150
 
115
151
  The list is yours to edit; the generator only reads it and carries it forward. Delete a name to get the artifact back on the next run. When two companion gems ship the same artifact name, both install under a `<name>--<source-gem>` suffix — the plain name disables both, the suffixed name disables one.
116
152
 
117
- To skip installed content wholesale instead, pass `--skip-content`.
153
+ To skip installed content wholesale instead, pass `--skip-content` to `hyperdrive:init`.
118
154
 
119
- ### Companion gem contract
120
-
121
- A companion gem ships artifacts under:
155
+ ---
122
156
 
123
- ```
124
- <gem-source>/lib/<gem_name>/hyperdrive/skills/<name>/SKILL.md # skill (dir-per-skill)
125
- <gem-source>/lib/<gem_name>/hyperdrive/guidelines/<name>.md # guideline (flat file)
126
- ```
157
+ ## Safety
127
158
 
128
- Skills may ship under an additional root declared in gemspec metadata:
159
+ Rails Hyperdrive is **dev-only**, enforced in depth: the engine refuses to handle requests outside `Rails.env.development?`, enforces an origin allowlist (`localhost`, `127.0.0.1`, `[::1]`), and every tool re-checks the dev guard on invocation. `run_sql` accepts read-only statements and refuses anything else. See [SECURITY.md](SECURITY.md).
129
160
 
130
- ```ruby
131
- spec.metadata["rails_hyperdrive_skills_dir"] = "extra/skills" # optional; relative to the gem root
132
- ```
161
+ ---
133
162
 
134
- That root is searched **in addition to** the convention path, never instead of it, so an override never hides skills already shipped at the convention path. A value containing a `..` segment is ignored. Guidelines have no override — they are found only at the convention path.
163
+ ## Build a companion gem
135
164
 
136
- A skill is a **directory**, and it may ship more than `SKILL.md`. Everything else in the skill directory — nested however you like (`workflows/`, `references/`, `examples/`, …) — installs alongside it as **supporting files**, preserving the relative layout under `.claude/skills/<name>/`. Reference them from `SKILL.md` with directory-relative links; a cross-source name collision renames the whole installed directory, so those links keep working. Supporting files carry no frontmatter contract and no audit header — they install byte-identical to the install-ready body, which is what the gem ships (markdown, code, or binary alike) except for `*.md.erb` templates, which install as their rendered output. Each is tracked per file in `.hyperdrive/lock.yml`, so local edits are preserved on sync exactly like any other installed file. `SKILL.md` frontmatter remains the skill's sole schema surface. Guidelines stay single-file.
165
+ Ship markdown at a convention path, declare what it targets, publish. That's the whole contract:
137
166
 
138
- Every artifact carries four required YAML frontmatter fields:
167
+ ```
168
+ lib/<gem_name>/hyperdrive/skills/<name>/SKILL.md # skill (dir-per-skill, may ship supporting files)
169
+ lib/<gem_name>/hyperdrive/guidelines/<name>.md # guideline (flat file)
170
+ ```
139
171
 
140
172
  ```yaml
141
173
  ---
@@ -146,83 +178,19 @@ versions: ">= 7.0, < 9.0" # Gem::Requirement matched against the target
146
178
  ---
147
179
  ```
148
180
 
149
- `name:` is the artifact's identity, not a label — it is what the installer writes to disk (`.claude/skills/<name>/SKILL.md`, `.claude/hyperdrive/guidelines/<name>.md`). Keep it equal to the file or directory stem: if the two disagree the install still succeeds, but the artifact lands under `name:`. Within one gem, two artifacts of the same type declaring the same `name:` collapse to a single installed file; which one survives is not a guarantee to build on, so give each a distinct `name:`.
150
-
151
- `versions:` accepts a single comma-separated string (`">= 7.0, < 9.0"`), a YAML list (`[">= 7.0", "< 9.0"]`), or — for multi-target artifacts — a map keyed by gem name.
152
-
153
- `gem:` names the **targets** (each must be present in the bundle; its resolved version is matched against `versions:`). Use `railties` for "every Rails app" or the quoted `"*"` for "always applicable" (it must be quoted — bare `*` is a YAML alias and the file is skipped). `hyperdrive:init` discovers every such file across the bundle, version-matches it, and installs it with an audit header naming `source`, `sha256`, and `installed_at`. Guidelines are installed with their frontmatter stripped (they are `@`-included eagerly). When two gems ship a same-named artifact, both install, each postfixed by source gem.
154
-
155
- One artifact can cover several interchangeable libraries — write `gem:` as a comma-separated string or a YAML list, and it installs when **any** listed target is bundled at a satisfying version. `"*"` anywhere in the list makes the artifact universal. Give `versions:` a map keyed by gem name when the targets do not share a version cycle; targets the map omits are unconstrained.
156
-
157
- ```yaml
158
- ---
159
- name: jobs-conventions
160
- description: Background job conventions.
161
- gem: [sidekiq, solid_queue, good_job]
162
- versions:
163
- sidekiq: ">= 7.0"
164
- solid_queue: ">= 1.0"
165
- ---
166
- ```
167
-
168
- Draw the listed targets from the gem's own `hyperdrive_targets` (below): the gem-level declaration decides whether a companion is suggested at all, and an artifact naming a target the gemspec omits is unreachable for apps that have only that target.
169
-
170
- #### Gem-conditional skill content
171
-
172
- A multi-file skill can condition parts of itself on the app's bundle, so one skill tree serves apps with different gem sets. Both mechanisms are evaluated at discovery time, against the same resolved bundle that gates whole artifacts.
173
-
174
- **Per-file gating.** A `conditional:` map in `SKILL.md` frontmatter gates individual supporting files. Keys are dir-relative shipped paths; values take the same `gem:`/`versions:` forms as the artifact-level fields (single target, comma-separated string, YAML list, per-target `versions:` map, `"*"`), and the file installs when **any** listed target is bundled at a satisfying version. Unlike the artifact level, `versions:` is optional here — omitted means unconstrained. Files the map doesn't mention install unconditionally, and the supporting files themselves stay byte-identical to upstream — the condition lives entirely out-of-band.
175
-
176
- ```yaml
177
- ---
178
- name: layered-rails
179
- description: Layered architecture conventions.
180
- gem: railties
181
- versions: ">= 7.2"
182
- conditional:
183
- references/gems/alba.md:
184
- gem: alba
185
- references/gems/jobs.md:
186
- gem: [sidekiq, solid_queue]
187
- versions:
188
- sidekiq: ">= 7.0"
189
- ---
190
- ```
191
-
192
- A malformed condition **fails open**: the file installs unconditionally and the problem is reported with the other discovery warnings — a surplus reference file is harmless, a missing one breaks links from `SKILL.md`. A key naming no shipped file, or naming `SKILL.md` itself (the artifact-level `gem:`/`versions:` gate the whole skill), is warned about and ignored. The `conditional:` key ships through to the installed frontmatter unchanged.
193
-
194
- **ERB-templated markdown.** A file named `*.md.erb` in a skill directory — including `SKILL.md.erb` in place of `SKILL.md` — is rendered at install time and lands as plain `.md` (the `.erb` suffix is dropped; a `SKILL.md.erb` defines a skill exactly like `SKILL.md`, with frontmatter read from the rendered output). Templates see a sealed binding of exactly three helpers over the resolved bundle, nothing else:
195
-
196
- - `gem?("name")` / `gem?("name", ">= 2.0")` — is the gem bundled (at a satisfying version)?
197
- - `any_gem?("a", "b", …)` — is any of these bundled?
198
- - `gem_version("name")` — the resolved version as a String, or `nil`.
199
-
200
- Rendering uses ERB's trim mode, so `<%- if gem?("alba") -%>` … `<%- end -%>` control lines leave no blank lines behind. A template that fails to render is skipped with a warning (the whole skill, when it's `SKILL.md.erb`); when a plain file and a template would land at the same path, the plain file wins with a warning. `conditional:` keys refer to templates by their shipped `x.md.erb` name and gate them before rendering. Guidelines get no ERB support.
201
-
202
- Use ERB sparingly — condition reference manuals via `conditional:` and wrap link-table rows that point at gated files, but keep "consider adopting gem X" recommendations unconditional. An all-wrapped `.md.erb` renders to an empty file; to omit a file entirely, gate it with `conditional:` instead.
203
-
204
- Gated files appear and disappear as the bundle changes: `hyperdrive:init`/`hyperdrive:sync` install newly gated-in files and remove unedited gated-out ones. The auto top-up after `bundle install` adds newly gated-in files but never removes anything and never rewrites an already-installed file, so removals and re-rendered template output wait for the next `hyperdrive:sync`.
205
-
206
- Discovery never raises. An artifact with missing or malformed frontmatter, a missing required field, no declared target in the bundle, or every bundled target resolving outside `versions:` is skipped, and the reason is collected. `hyperdrive:init` and `hyperdrive:sync` print the collected reasons at the end of the run, under a yellow `warn` line reading `discovery skipped N artifact(s):`. A companion whose artifacts all fail therefore installs nothing and reports it only there — read that section first when a gem you expected to contribute produces no files.
207
-
208
- To be discoverable by `hyperdrive:discover` **before** it is installed, a companion also declares gemspec metadata (read remotely from rubygems, so the frontmatter inside the gem isn't visible yet):
181
+ And to be suggested by `hyperdrive:discover` before anyone installs you:
209
182
 
210
183
  ```ruby
211
- spec.metadata["rails_hyperdrive_targets"] = "sidekiq" # required; comma-sep, or "*" for always-applicable
212
- spec.metadata["rails_hyperdrive_artifacts"] = "guideline,skill" # optional; presentational hint
184
+ spec.metadata["rails_hyperdrive_targets"] = "sidekiq"
213
185
  ```
214
186
 
215
- `rails_hyperdrive_targets` is what makes a gem discoverable: `hyperdrive:discover` searches rubygems for gems declaring it, so a companion is found by what it declares rather than by what it is named. Naming it `rails-hyperdrive-<library>` is a recommended convention it makes the gem legible in a Gemfile but it plays no part in discovery, and a companion published under your own namespace is found on the same terms.
216
-
217
- `rails_hyperdrive_targets` is a coarse pre-install hint — it is never reconciled against the frontmatter `gem:`; once the gem is bundled, the frontmatter alone governs what installs.
187
+ The full contract multi-target artifacts, multi-file skills, per-file gem gating, ERB-templated content, collision and dedup ruleslives in [docs/COMPANION_GEMS.md](docs/COMPANION_GEMS.md).
218
188
 
219
189
  ---
220
190
 
221
- ## Safety
222
-
223
- Rails Hyperdrive is **dev-only**. The engine refuses to handle requests outside `Rails.env.development?` and enforces an origin allowlist (`localhost`, `127.0.0.1`, `[::1]`). See [SECURITY.md](SECURITY.md).
191
+ ## Requirements
224
192
 
225
- ---
193
+ Ruby ≥ 3.2, Rails ≥ 7.2. Tested against Rails 7.2 and 8.1 on Ruby 3.2–3.4.
226
194
 
227
195
  ## License
228
196
 
@@ -4,7 +4,7 @@ Description:
4
4
  merging into any servers already configured there
5
5
  - mounts Rails::Hyperdrive::Engine at /_hyperdrive in config/routes.rb (idempotent)
6
6
  - discovers companion-gem skills + guidelines in the bundle and installs
7
- them with audit headers (skills to .claude/skills/, guidelines to
7
+ them (skills to .claude/skills/, guidelines to
8
8
  .claude/hyperdrive/guidelines/)
9
9
  - maintains the .claude/hyperdrive/index.md aggregator and injects one
10
10
  @-include line into CLAUDE.md — both only while a companion gem ships
@@ -13,7 +13,8 @@ Description:
13
13
 
14
14
  Re-running re-syncs content and leaves locally-modified files untouched
15
15
  (skip + warn). Routine content refresh is bin/rails hyperdrive:sync
16
- (pass --overwrite to restore gem-shipped content).
16
+ (see its --merge, --sidecar, and --overwrite flags for reconciling
17
+ locally-modified files).
17
18
 
18
19
  Examples:
19
20
  bin/rails hyperdrive:init
@@ -2,20 +2,34 @@ Description:
2
2
  Syncs Rails Hyperdrive content to match the current bundle (run after
3
3
  `bundle update` or after adding a companion gem):
4
4
  - discovers companion-gem skills + guidelines in the bundle and installs
5
- them with audit headers (skills to .claude/skills/, guidelines to
5
+ them (skills to .claude/skills/, guidelines to
6
6
  .claude/hyperdrive/guidelines/)
7
7
  - maintains the .claude/hyperdrive/index.md aggregator and the single
8
8
  @-include line in CLAUDE.md — both only while a companion gem ships a
9
9
  guideline, and both removed when the last one goes
10
10
  - tracks everything in .hyperdrive/lock.yml
11
11
 
12
- Locally-modified files are left untouched (skip + warn). Pass --overwrite
13
- to restore them to the gem-shipped content.
12
+ Locally-modified files are left untouched by default (skip + warn). Three
13
+ mutually-exclusive strategies reconcile them with a new upstream:
14
+ --merge three-way-merge the upstream change into the live file
15
+ (clean merges only; anything else falls back to --sidecar,
16
+ so no conflict marker ever lands in a live file)
17
+ --sidecar write the new upstream body to <file>.new next to the live
18
+ file, leaving the live file untouched
19
+ --overwrite restore the live file to the gem-shipped content,
20
+ discarding local edits
21
+
22
+ Resolving a sidecar: fold what you want from <file>.new into the live
23
+ file, then delete <file>.new — or `mv <file>.new <file>` to accept the
24
+ upstream wholesale. Either way the next sync is quiet: the lock already
25
+ records the delivered upstream, so the same version is never re-offered.
14
26
 
15
27
  Touches no bootstrap artifact: .mcp.json, the engine mount, the optional
16
28
  initializer, and .gitignore belong to hyperdrive:init and are left alone.
17
29
 
18
30
  Examples:
19
31
  bin/rails hyperdrive:sync
32
+ bin/rails hyperdrive:sync --merge
33
+ bin/rails hyperdrive:sync --sidecar
20
34
  bin/rails hyperdrive:sync --overwrite
21
35
  bin/rails hyperdrive:sync --dry-run
@@ -15,8 +15,17 @@ module Rails
15
15
  source_root File.expand_path("templates", __dir__)
16
16
 
17
17
  class_option :overwrite, type: :boolean, default: false, desc: "Restore locally-modified managed files to the gem-shipped content."
18
+ class_option :merge, type: :boolean, default: false, desc: "Three-way-merge upstream changes into locally-modified files; falls back to sidecar delivery."
19
+ class_option :sidecar, type: :boolean, default: false, desc: "Deliver upstream changes for locally-modified files to <file>.new sidecars."
18
20
  class_option :dry_run, type: :boolean, default: false, desc: "Show what would change; write nothing."
19
21
 
22
+ def verify_options
23
+ chosen = %i[overwrite merge sidecar].select { |flag| options[flag] }
24
+ return if chosen.size <= 1
25
+ raise Thor::Error,
26
+ "hyperdrive: #{chosen.map { |flag| "--#{flag}" }.join(" and ")} are mutually exclusive; pick one"
27
+ end
28
+
20
29
  def verify_environment
21
30
  runner.verify_environment!
22
31
  end
@@ -26,7 +35,13 @@ module Rails
26
35
  end
27
36
 
28
37
  def sync_content
29
- runner.install(mode: options[:overwrite] ? :overwrite : :preserve)
38
+ mode =
39
+ if options[:overwrite] then :overwrite
40
+ elsif options[:merge] then :merge
41
+ elsif options[:sidecar] then :sidecar
42
+ else :preserve
43
+ end
44
+ runner.install(mode: mode)
30
45
  end
31
46
 
32
47
  def print_summary
@@ -0,0 +1,76 @@
1
+ require "bundler"
2
+ require "rails/hyperdrive/bundler_artifact_discovery"
3
+ require "rails/hyperdrive/drift_verdict"
4
+ require "rails/hyperdrive/skill_template"
5
+
6
+ module Rails
7
+ module Hyperdrive
8
+ # Best-effort reconstruction of the install-ready body an artifact had at
9
+ # the gem version a lock entry records, read from installed gem
10
+ # directories. The result is sha-gated against the lock entry: anything
11
+ # that does not rebuild to exactly the recorded bytes counts as
12
+ # unavailable.
13
+ module AncestorLocator
14
+ module_function
15
+
16
+ # Never raises; any failure returns nil (ancestor unavailable).
17
+ def locate(kind:, relpath:, lock_entry:, final_name: nil, gem_paths: Gem.path, resolved: nil)
18
+ return nil unless lock_entry&.source_gem && lock_entry.source_version && lock_entry.source_sha
19
+ return nil if relpath.nil? || relpath.to_s.empty?
20
+
21
+ Array(gem_paths).each do |home|
22
+ gem_root = File.join(home.to_s, "gems", "#{lock_entry.source_gem}-#{lock_entry.source_version}")
23
+ body = read_candidate(gem_root, relpath.to_s, kind: kind, resolved: resolved)
24
+ next unless body
25
+
26
+ ready = install_ready(body, kind: kind, final_name: final_name)
27
+ return ready if DriftVerdict.body_sha(ready) == lock_entry.source_sha
28
+ rescue StandardError
29
+ next
30
+ end
31
+ nil
32
+ rescue StandardError
33
+ nil
34
+ end
35
+
36
+ def read_candidate(gem_root, relpath, kind:, resolved:)
37
+ exact = File.join(gem_root, relpath)
38
+ if File.file?(exact)
39
+ return kind.to_s == "skill_support" ? File.binread(exact) : File.read(exact)
40
+ end
41
+ return nil unless relpath.end_with?(".md")
42
+
43
+ twin = "#{exact}.erb"
44
+ return nil unless File.file?(twin)
45
+
46
+ map = resolved || resolved_bundle
47
+ return nil unless map
48
+ SkillTemplate.render(File.read(twin), resolved: map)
49
+ end
50
+
51
+ def install_ready(body, kind:, final_name:)
52
+ case kind.to_s
53
+ when "skill"
54
+ ready = BundlerArtifactDiscovery.install_ready_body(
55
+ BundlerArtifactDiscovery::Artifact.new(artifact_type: :skill, body: body)
56
+ )
57
+ final_name ? ready.sub(/^name:\s*.+$/, "name: #{final_name}") : ready
58
+ when "guideline"
59
+ BundlerArtifactDiscovery.install_ready_body(
60
+ BundlerArtifactDiscovery::Artifact.new(artifact_type: :guideline, body: body)
61
+ )
62
+ else
63
+ body
64
+ end
65
+ end
66
+
67
+ def resolved_bundle
68
+ ::Bundler.load.specs.to_a.each_with_object({}) { |s, h| h[s.name.to_s] = s.version }
69
+ rescue StandardError
70
+ nil
71
+ end
72
+
73
+ private_class_method :read_candidate, :install_ready, :resolved_bundle
74
+ end
75
+ end
76
+ end
@@ -15,7 +15,7 @@ module Rails
15
15
  case state
16
16
  when :missing then "#{path} (from #{bundle_source})"
17
17
  when :outdated then "#{path} (#{locked_source} → #{bundle_source})"
18
- when :orphaned then "#{path} (source #{locked_source} no longer in bundle)"
18
+ when :orphaned then "#{path} (no longer shipped by #{locked_source})"
19
19
  else path
20
20
  end
21
21
  end
@@ -6,10 +6,12 @@ module Rails
6
6
  module Hyperdrive
7
7
  module BundlerArtifactDiscovery
8
8
  SKILL_FILE_NAMES = ["SKILL.md", "SKILL.md.erb"].freeze
9
+ INSTALLER_KEY = /\A(?:gem|versions|conditional):/.freeze
9
10
 
10
11
  Artifact = Struct.new(
11
12
  :name, :description, :target_gem, :versions, :artifact_type,
12
13
  :source_gem, :path, :body, :spec_version, :support_files,
14
+ :source_root,
13
15
  keyword_init: true
14
16
  ) do
15
17
  def skill?
@@ -133,6 +135,7 @@ module Rails
133
135
  path: path,
134
136
  body: body,
135
137
  spec_version: source_spec.version.to_s,
138
+ source_root: source_spec.full_gem_path.to_s,
136
139
  support_files:
137
140
  if type == :skill
138
141
  conditioned_support_files(
@@ -254,12 +257,35 @@ module Rails
254
257
  end
255
258
 
256
259
  def install_ready_body(artifact)
257
- return artifact.body if artifact.skill?
260
+ return strip_installer_keys(artifact.body) if artifact.skill?
258
261
 
259
262
  _frontmatter, rest = split_frontmatter(artifact.body)
260
263
  (rest || artifact.body).sub(/\A\n+/, "")
261
264
  end
262
265
 
266
+ # gem:/versions:/conditional: are install-time inputs with no reader
267
+ # after install, and conditional: keys name *shipped* paths that gating
268
+ # and ERB retargeting can leave pointing at files absent from disk — so
269
+ # the installed frontmatter carries neither.
270
+ def strip_installer_keys(body)
271
+ frontmatter, rest = split_frontmatter(body)
272
+ return body unless frontmatter
273
+
274
+ kept = []
275
+ skipping = false
276
+ frontmatter.lines.each do |line|
277
+ if line =~ INSTALLER_KEY
278
+ skipping = true
279
+ elsif skipping && (line.start_with?(" ", "\t") || line.strip.empty?)
280
+ # continuation of a stripped key's block
281
+ else
282
+ skipping = false
283
+ kept << line
284
+ end
285
+ end
286
+ "---\n#{kept.join}---\n#{rest}"
287
+ end
288
+
263
289
  def split_frontmatter(body)
264
290
  lines = body.lines
265
291
  return [nil, body] unless lines.first&.strip == "---"
@@ -313,6 +339,7 @@ module Rails
313
339
  :conditioned_support_files, :apply_conditional_filter,
314
340
  :conditional_satisfied?, :malformed_requirements?,
315
341
  :render_support_templates, :erb_template?,
342
+ :strip_installer_keys,
316
343
  :split_frontmatter, :parse_targets, :match_targets,
317
344
  :version_satisfied?, :no_match_reason, :safe_bundler_specs
318
345
  end