rails-hyperdrive 0.8.0 → 0.9.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: 65854e72d10acf60fd5140b816d8ee96bc6f573679dd74e7aeb369cbad9f572a
4
- data.tar.gz: d86dd2e00fe05e063861b6a408c78dc38809a541cc39fa63a6ab5ca709e88bc3
3
+ metadata.gz: b2a834f37a237a640a01631dc04ab991791e87535b90cb5fc7e44f69e24dccd0
4
+ data.tar.gz: 03dd055200f9407f86719da8a221207b1b0a5987e12657284399ce5d8fae2840
5
5
  SHA512:
6
- metadata.gz: a4a279ebaaf70cf2cd43d050d8d031b0ddc45f0e7790e49954dc4f8c96e1a0733c61c3bb4a1ee365ed076992f29a47f00a40707c964328d11f63b5130cd1ce28
7
- data.tar.gz: c84c36f0563a52ae2938a998167f2287d68ec586f355794b48c6b79887446fac5cc127f20e0a260613aaeb43f8d9d3187fd198b72a51f3ab7482b20ab5a84f41
6
+ metadata.gz: cc5dcb6940b29b6bf182e704f9419d336616fe999dc214325697d3b6a9aa4673fca9b003ba4a621eb348ea0be34dc892f648a1e6882add7cf261b7710a29d597
7
+ data.tar.gz: b810742750f5bd8a747a1c27fee799d80164bb306ad69ad4ff0445a8a6deefdde1171922058ee325d55b8df2a1b187a2e0e3fb2ab2ec41a204ccbce50bdca228
data/CHANGELOG.md CHANGED
@@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-09-05
11
+
12
+ ### Added
13
+
14
+ - **A pending sidecar now remembers what your edits were based on.** While
15
+ `<file>.new` is waiting, `.hyperdrive/lock.yml` also records the upstream the
16
+ live file was edited on top of. `hyperdrive:sync --merge` uses it to attempt a
17
+ real three-way merge over a file that already has a sidecar — including one
18
+ where the same upstream is still on offer — instead of refusing outright, and
19
+ `--resolve` can hand its tool a `$BASE` (and a `$PREVIOUS_SOURCE`) on every
20
+ retry, not just on the run that wrote the sidecar. The keys are written on
21
+ delivery, kept put when a newer upstream refreshes the sidecar, and dropped by
22
+ the first run that finds the sidecar gone; deleting `<file>.new` is still the
23
+ only thing that marks a delivery resolved.
24
+
25
+ - **`hyperdrive:sync --resolve`, a `git mergetool` for sidecars.** It delivers
26
+ to sidecars on its own (or after `--merge` has taken what git can), hands every
27
+ unresolved `<file>.new` to the command you name in `resolve.command` in
28
+ `.hyperdrive/config.yml`, and deletes the sidecar when that command exits 0; any other exit leaves the live file, the
29
+ sidecar, and the lockfile untouched with the reason printed. No command ships
30
+ with the gem, and `--resolve` without one configured stops before syncing
31
+ anything. The command receives `$LOCAL`, `$REMOTE`, `$BASE`, `$MERGED`,
32
+ `$SOURCE`, `$PREVIOUS_SOURCE`, `$KIND`, and `$PROMPT` — substituted per
33
+ argument and exported as `HYPERDRIVE_*` variables — where `$PROMPT` is a
34
+ tool-neutral set of reconciliation instructions you can replace with your own
35
+ ERB template via `resolve.prompt:`. A sidecar you edited yourself is never
36
+ handed off, `--dry-run` runs nothing, and the resolver never runs during
37
+ `bundle install`.
38
+
39
+ - **`.hyperdrive/config.yml`, a settings file that belongs to you.** The
40
+ hand-edited `disabled:` (per kind) and `enabled:` (companion opt-in) lists now
41
+ live there instead of in `.hyperdrive/lock.yml`. `hyperdrive:init` creates the
42
+ file with empty sections and an explanatory header if it is not already there,
43
+ and no hyperdrive command ever writes to it again — the relationship is
44
+ `Gemfile` to `Gemfile.lock`: the config holds your choices, the lock holds
45
+ state. Reads are fail-open: a missing file is an empty config, and anything
46
+ malformed warns and is ignored rather than failing a run. A gitignored config
47
+ now draws the same warning a gitignored lock does.
48
+
49
+ ### Changed
50
+
51
+ - **`hyperdrive:sync --merge` now says what a clean merge is.** When a run
52
+ merges anything, the summary carries one footer line: a clean three-way merge
53
+ is textually non-overlapping, not verified, and `git diff` is where you check
54
+ what it produced. Nothing about merging itself changed; a run that merged
55
+ nothing prints no footer.
56
+ - **BREAKING — `disabled:`/`enabled:` in `.hyperdrive/lock.yml` are no longer
57
+ read, and there is no migration.** `init`, `sync`, and `bundle install` print
58
+ one warning naming the lock keys, and the next lock write drops them. Re-declare
59
+ those settings in `.hyperdrive/config.yml` first: otherwise every artifact you
60
+ had disabled installs again, and a gem that was opted in only through the
61
+ lock's `enabled:` list is no longer a companion, so the next `init` or `sync`
62
+ removes its unedited artifacts as stale (edited ones are warned about and
63
+ left).
64
+ - The lock schema version is now **3**. A 0.8.0 installer halts on a schema-3
65
+ lock instead of reading the absent `disabled:` list as empty and reinstalling
66
+ every artifact you opted out of; installers before 0.8.0 ship no schema guard
67
+ and do reinstall them.
68
+
10
69
  ## [0.8.0] - 2026-08-26
11
70
 
12
71
  ### Added
@@ -754,7 +813,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
754
813
  - `hyperdrive:init` generator that installs architecture skills and auto-discovers
755
814
  per-gem skills.
756
815
 
757
- [Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.8.0...HEAD
816
+ [Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.9.0...HEAD
817
+ [0.9.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.9.0
758
818
  [0.8.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.8.0
759
819
  [0.7.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.7.0
760
820
  [0.6.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.6.0
data/README.md CHANGED
@@ -38,7 +38,10 @@ $ bundle add rails-hyperdrive-sidekiq --group=development
38
38
  $ bin/rails hyperdrive:init
39
39
 
40
40
  create .mcp.json
41
+ append .gitignore
42
+ append Gemfile
41
43
  insert config/routes.rb
44
+ create .hyperdrive/config.yml
42
45
  create .claude/hyperdrive/guidelines/jobs-sidekiq.md
43
46
  create .claude/skills/sidekiq-idempotency/SKILL.md
44
47
  create .claude/hyperdrive/index.md
@@ -49,12 +52,17 @@ $ bin/rails hyperdrive:init
49
52
  done hyperdrive initialized
50
53
  Mount: /_hyperdrive (in config/routes.rb)
51
54
  Server: 8 MCP tools at http://localhost:3000/_hyperdrive/mcp
52
- Installed 1 skill, 1 guideline
55
+ Installed 1 skill, 1 guideline, 0 agents, 0 commands
53
56
 
54
57
  rails-hyperdrive-sidekiq@1.2.0
55
58
  skill sidekiq-idempotency
56
59
  guideline jobs-sidekiq
57
60
 
61
+ Next steps:
62
+ 1. bin/rails server
63
+ 2. Open Claude Code in this directory; it will read .mcp.json
64
+ 3. Verify the connection: curl -s http://localhost:3000/_hyperdrive/mcp ...
65
+
58
66
  # 4. Start the dev server
59
67
  $ bin/dev
60
68
 
@@ -63,7 +71,7 @@ $ bin/dev
63
71
  # → agent has 8 tools, the eager guidelines (via CLAUDE.md), and the lazy skills
64
72
  ```
65
73
 
66
- The generated `.mcp.json` points at `http://localhost:3000<mount>/mcp`. If your dev server runs on another port, edit the URL there.
74
+ The engine mounts at `/_hyperdrive` by default; `--mount-at /some/path` moves both the route and the URL written to `.mcp.json`. The generated `.mcp.json` points at `http://localhost:3000<mount>/mcp`, so if your dev server runs on another port, edit the URL there.
67
75
 
68
76
  ---
69
77
 
@@ -74,12 +82,12 @@ The generated `.mcp.json` points at `http://localhost:3000<mount>/mcp`. If your
74
82
  | # | Tool | Purpose |
75
83
  |---|------|---------|
76
84
  | 1 | `run_ruby` | Eval Ruby in the booted Rails process, with timeout + output capture |
77
- | 2 | `run_sql` | Read-only SQL via the AR connection (refuses non-SELECT) |
85
+ | 2 | `run_sql` | Read-only SQL via the AR connection (`SELECT`/`WITH`/`EXPLAIN`/`SHOW`/`PRAGMA` only; 100 rows max) |
78
86
  | 3 | `tail_logs` | Tail the last N lines of a log under `log/` (defaults to `log/<env>.log`) |
79
87
  | 4 | `list_models` | List Active Record model classes with columns/validations/associations |
80
88
  | 5 | `locate_source` | Resolve `Const` / `Const#method` / `Const.method` / `dep:<gem>` to a file:line |
81
89
  | 6 | `lookup_doc` | Look up RDoc for a symbol (via `ri`) |
82
- | 7 | `describe_app` | Snapshot: Rails/Ruby/DB versions + direct gem dependencies |
90
+ | 7 | `describe_app` | Snapshot: Rails/Ruby/DB versions, direct gem dependencies, installed skills |
83
91
  | 8 | `list_routes` | All routes: HTTP verb, path, controller#action, named route |
84
92
 
85
93
  Plus two MCP resources: `hyperdrive://stack-profile` (JSON snapshot of your resolved stack) and `hyperdrive://skills/{name}` (the markdown body of each installed skill). The skill list is enumerated at server boot and the stack snapshot is memoized per process, so a newly installed skill or a changed bundle reaches these two only after a dev-server restart.
@@ -95,26 +103,73 @@ Companion gems ship four artifact kinds, tuned for how agents consume context:
95
103
 
96
104
  A companion gem declares in its manifest which gem each artifact targets and at which versions, so what lands in your app is what matches your `Gemfile.lock`, and nothing aimed at a library or version you don't run.
97
105
 
98
- 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. The two halves are independently skippable: `--skip-mcp` installs content but no MCP plumbing, `--skip-content` the reverse. Neither removes anything already in place.
106
+ With no companion gems, `hyperdrive:init` sets up just the plumbing (`.mcp.json`, the engine mount, the bundler plugin line in your `Gemfile`, a `.gitignore` rule for the discover cache, an empty `.hyperdrive/config.yml`, and the lockfile) and puts **nothing** into your agent's context window. The two halves are independently skippable: `--skip-mcp` writes no `.mcp.json` entry and no mount; `--skip-content` skips the content, the config file, and the lockfile and leaves the rest. Neither removes anything already in place.
99
107
 
100
108
  ---
101
109
 
102
110
  ## Staying in sync
103
111
 
104
- **After `bundle install`: automatic.** `hyperdrive:init` registers the [`bundler-hyperdrive`](bundler-hyperdrive/) Bundler plugin in your Gemfile. From then on, adding a companion gem lands its artifacts on that very `bundle install`, with no extra command to run. 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`.
112
+ **After `bundle install`: automatic.** `hyperdrive:init` registers the [`bundler-hyperdrive`](bundler-hyperdrive/) Bundler plugin in your Gemfile. From then on, adding a companion gem lands its artifacts on that very `bundle install`, with no extra command to run. 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`. It runs only where an install is safe to touch: a development environment (`RAILS_ENV`/`RACK_ENV` unset or `development`), no `CI` variable, no frozen bundle, and an app that already has a `.hyperdrive/lock.yml` (it tops up an initialized app, never bootstraps one). Because it never edits `CLAUDE.md`, a guideline that first arrives this way is not in context until the next `hyperdrive:sync` wires it in; the plugin says so when that happens.
113
+
114
+ **`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 with no flags it leaves locally modified files untouched (skip + warn). When you *have* edited an installed file and its gem ships a new version, one flag picks the strategy:
105
115
 
106
- **`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:
116
+ | Flag | What happens to the live file | What happens to your edits | Falls back to |
117
+ |---|---|---|---|
118
+ | *(none)* | Untouched, with a warning naming the flags below | Kept | — |
119
+ | `--merge` | Rewritten with a git three-way merge (base = the gem version you last installed, ours = your file, theirs = the new upstream) when it applies cleanly | Kept | `--sidecar`, whenever git cannot produce a clean result |
120
+ | `--sidecar` | Untouched; the new upstream body is written next to it as `<file>.new` | Kept, byte-for-byte | — |
121
+ | `--overwrite` | Restored to the gem-shipped content | Discarded | — |
122
+ | `--resolve` | Delivers as `--sidecar` (or as `--merge`, when both are given), then hands each unresolved `<file>.new` to a command you configure; exit 0 deletes the sidecar | Up to your command | — |
107
123
 
108
- | Strategy | What happens to the live file | What happens to your edits |
109
- |---|---|---|
110
- | `--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, so nothing half-merged ever goes live |
111
- | `--sidecar` | Untouched; the new upstream body is written next to it as `<file>.new` | Kept, byte-for-byte |
112
- | `--overwrite` | Restored to the gem-shipped content | Discarded |
124
+ `--merge`, `--sidecar`, and `--overwrite` are mutually exclusive. `--resolve` stacks on top: alone it means "sidecar, then resolve", `--merge --resolve` means "git merges what it can, your command takes the rest", and `--overwrite --resolve` is rejected because an overwrite leaves nothing to resolve. Every combination accepts `--dry-run`, which prints the plan and writes nothing.
113
125
 
114
- 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.
126
+ The fallback from `--merge` to a sidecar is what keeps a half-merged file from ever going live. It happens, with the reason on the status line, when the merge would need conflict markers; when the gem version you last installed is no longer on disk (CI, after `gem cleanup`), so there is no ancestor to merge against; when the file has no earlier install recorded in the lockfile; when the content is binary; or when `git` is not on your PATH. A clean merge is textually non-overlapping, not semantically correct: git merges two contradictory edits cleanly when unchanged lines sit between them, so `git diff` after a `--merge` run is a review step, not a formality.
127
+
128
+ 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). A file that already has a sidecar is not out of reach: while one is pending the lockfile also records the version your edits were based on, so a later `--merge` still has a proper base to give git — including for the delivery already waiting in the `.new`.
115
129
 
116
130
  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.
117
131
 
132
+ **`--resolve`: hand the sidecars to a tool.** `--resolve` automates that last step, `git mergetool` style. After delivery it hands every unresolved `<file>.new` — from this run or left over from an earlier one — to the command named in `.hyperdrive/config.yml`, and deletes the sidecar when that command exits 0. The gem ships no command of its own, so nothing runs that you did not name:
133
+
134
+ ```yaml
135
+ # .hyperdrive/config.yml
136
+ resolve:
137
+ command: claude -p --permission-mode acceptEdits $PROMPT
138
+ ```
139
+
140
+ ```sh
141
+ bin/rails hyperdrive:sync --resolve # every edited file goes to your tool
142
+ bin/rails hyperdrive:sync --merge --resolve # git merges what it can, your tool takes the rest
143
+ git diff # then you review
144
+ ```
145
+
146
+ The command is split with shell word rules and run with no shell, from the app root. Each argument gets these placeholders substituted, and every one is also exported as an environment variable (`$LOCAL` → `HYPERDRIVE_LOCAL`, and so on), so a wrapper script needs no argument parsing:
147
+
148
+ | Placeholder | Value |
149
+ |---|---|
150
+ | `$LOCAL` | The live file, with your edits |
151
+ | `$REMOTE` | The sidecar: the new upstream body |
152
+ | `$BASE` | The common ancestor — available whenever the lock records the pending delivery's ancestor and that gem version is still on disk; otherwise the argument is dropped |
153
+ | `$MERGED` | The live file again: the file your command must write |
154
+ | `$SOURCE` | `<gem>@<version>` of the new upstream |
155
+ | `$PREVIOUS_SOURCE` | `<gem>@<version>` your copy is based on — `$BASE`'s source — when the lockfile records one |
156
+ | `$KIND` | `skill`, `guideline`, `agent`, `command`, or `skill_support` |
157
+ | `$PROMPT` | Ready-made instructions for an agent, naming the paths above |
158
+
159
+ `$BASE` is a temporary file outside the app, removed as soon as the command returns, so a tool that is confined to the project directory needs it granted explicitly:
160
+
161
+ ```sh
162
+ #!/usr/bin/env bash
163
+ # bin/hyperdrive-resolve, used as `command: bin/hyperdrive-resolve`
164
+ args=(-p --permission-mode acceptEdits)
165
+ [ -n "$HYPERDRIVE_BASE" ] && args+=(--add-dir "$(dirname "$HYPERDRIVE_BASE")")
166
+ exec claude "${args[@]}" "$HYPERDRIVE_PROMPT"
167
+ ```
168
+
169
+ Replace the shipped prompt with your own ERB template — same placeholders, as lower-case locals (`local`, `remote`, `base`, `merged`, `source`, `previous_source`, `kind`) — with `resolve.prompt: .hyperdrive/resolve-prompt.md.erb`.
170
+
171
+ Exit 0 is a promise: the sidecar is deleted on the strength of it, whether or not `$MERGED` was written. Any other exit — including a command that is not on your PATH — leaves the live file, the sidecar, and the lockfile exactly as they were, with the reason printed, so a failed resolve is just an ordinary unresolved sidecar. `--resolve` runs only on an explicit `hyperdrive:sync`, never during `bundle install`. `--dry-run` prints what it would hand off and runs nothing. A sidecar you edited yourself is never handed to the command.
172
+
118
173
  **`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. It is read-only, caches results for 24h (`--refresh` re-queries), and never touches your Gemfile or makes network calls unless you invoke it.
119
174
 
120
175
  ---
@@ -133,20 +188,23 @@ CLAUDE.md # user-owned; ONE injected line: @.claude
133
188
  <supporting files> # optional extras (references/, examples/, …), installed as shipped (*.md.erb rendered)
134
189
  .claude/agents/<name>.md # companion-shipped subagent, installed verbatim
135
190
  .claude/commands/<name>.md # companion-shipped slash command, installed verbatim
191
+ .hyperdrive/config.yml # yours to edit: disabled:/enabled:/resolve: settings
136
192
  .hyperdrive/lock.yml # git-tracked manifest (source gem, version, content hash)
137
193
  ```
138
194
 
139
- 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`.
195
+ 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` and `hyperdrive:sync` warn 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`. The lockfile carries a schema version, and a rails-hyperdrive older than the one that wrote it stops with an upgrade message instead of rewriting state it cannot read.
196
+
197
+ `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). A `CLAUDE.md` you had before `hyperdrive:init` gets the one line appended, and tear-down strips just that line; a `CLAUDE.md` the installer created is deleted only while it is still byte-identical to what was written.
140
198
 
141
- `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).
199
+ Both links are yours to cut. Delete the `@.claude/hyperdrive/index.md` line from `CLAUDE.md` and no later run re-adds it (the lockfile remembers the choice, and the next run says so once). Delete a single `@guidelines/<name>.md` line from `index.md` and that guideline leaves eager context while staying installed; it is not re-added either, and an `index.md` you have emptied this way is kept because it is the record of those choices.
142
200
 
143
201
  ### Your edits win
144
202
 
145
- 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.
203
+ 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; `--merge` and `--sidecar` ([above](#staying-in-sync)) bring the upstream change in without losing yours.
146
204
 
147
205
  ### Turning off a single artifact
148
206
 
149
- A companion gem you want for one skill but not another doesn't have to be all-or-nothing. Add the artifact's name to the `disabled:` list in `.hyperdrive/lock.yml`. It is written empty on every install, so the shape is already there:
207
+ A companion gem you want for one skill but not another doesn't have to be all-or-nothing. Add the artifact's name to the `disabled:` list in `.hyperdrive/config.yml` `hyperdrive:init` creates that file with empty sections, so the shape is already there:
150
208
 
151
209
  ```yaml
152
210
  disabled:
@@ -162,26 +220,28 @@ disabled:
162
220
 
163
221
  A disabled artifact is never installed, and one already on disk is removed on the next `hyperdrive:init` or `hyperdrive:sync`, but **only if you haven't edited it**. A locally modified file is reported and left alone, for you to delete when you're ready. Disabling a skill removes its shipped supporting files under the same per-file rule; files you created yourself in the skill directory survive and keep the directory alive. Disabling a guideline also drops its line from `index.md`, so it leaves eager context along with the file.
164
222
 
165
- 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.
223
+ The list is yours to edit; the generator only reads it. 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.
224
+
225
+ Earlier releases kept `disabled:` and `enabled:` in `.hyperdrive/lock.yml`. They are no longer read from there: a lock still carrying them draws one warning and the next write drops them. Move the lists into `.hyperdrive/config.yml` first: otherwise every artifact you had disabled installs again, and a gem that was enabled only there stops counting as a companion, so the next `init` or `sync` removes its unedited artifacts as stale.
166
226
 
167
227
  To skip installed content wholesale instead, pass `--skip-content` to `hyperdrive:init`.
168
228
 
169
229
  ### Opting into a gem's bundled skills
170
230
 
171
- Ordinary gems (not built as hyperdrive companions) sometimes ship a top-level `skills/` directory of skills.sh-style skills. Those are never installed automatically: `hyperdrive:init` and `hyperdrive:sync` only report them, e.g. `gem 'foo' ships 2 skills.sh skill(s)`. To install them, name the gem in the `enabled:` list in `.hyperdrive/lock.yml` and re-run `hyperdrive:sync`:
231
+ Ordinary gems (not built as hyperdrive companions) sometimes ship a top-level `skills/` directory of skills.sh-style skills. Those are never installed automatically: `hyperdrive:init` and `hyperdrive:sync` only report them, e.g. `gem 'foo' ships 2 skills.sh skill(s)`. To install them, name the gem in the `enabled:` list in `.hyperdrive/config.yml` and re-run `hyperdrive:sync`:
172
232
 
173
233
  ```yaml
174
234
  enabled:
175
235
  - foo
176
236
  ```
177
237
 
178
- An enabled gem is treated as a companion from then on: its skills install through the normal pipeline (including on `bundle install`), and `disabled:` still wins for any individual artifact. The list is hand-edited like `disabled:` and survives every rewrite of the lockfile.
238
+ An enabled gem is treated as a companion from then on: its skills install through the normal pipeline (including on `bundle install`), and `disabled:` still wins for any individual artifact. The list is hand-edited like `disabled:`; `.hyperdrive/config.yml` is yours alone, and no hyperdrive command writes to it after `hyperdrive:init` creates it.
179
239
 
180
240
  ---
181
241
 
182
242
  ## Safety
183
243
 
184
- Rails Hyperdrive is **dev-only**, enforced in depth: the engine refuses requests outside `Rails.env.development?`, applies 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).
244
+ Rails Hyperdrive is **dev-only**, enforced in depth: the engine refuses requests outside `Rails.env.development?`, applies an origin allowlist (`localhost`, `127.0.0.1`, `[::1]`), and every tool re-checks the dev guard on invocation. The route line `hyperdrive:init` writes is itself guarded by `Rails.env.development?`, and an engine that loads outside development (the gem in the wrong Gemfile group, say) only logs a warning at boot. `run_sql` accepts read-only statements and refuses anything else. See [SECURITY.md](SECURITY.md).
185
245
 
186
246
  ---
187
247
 
@@ -13,6 +13,8 @@ Description:
13
13
  - maintains the .claude/hyperdrive/index.md aggregator and injects one
14
14
  @-include line into CLAUDE.md — both only while a companion gem ships
15
15
  a guideline, and both removed when the last one goes
16
+ - creates .hyperdrive/config.yml (your disabled:/enabled:/resolve: settings) if
17
+ it is not there yet, and never touches it again
16
18
  - tracks everything in .hyperdrive/lock.yml
17
19
 
18
20
  The .mcp.json entry and the engine mount are skippable with --skip-mcp;
@@ -20,8 +22,8 @@ Description:
20
22
 
21
23
  Re-running re-syncs content and leaves locally-modified files untouched
22
24
  (skip + warn). Routine content refresh is bin/rails hyperdrive:sync
23
- (see its --merge, --sidecar, and --overwrite flags for reconciling
24
- locally-modified files).
25
+ (see its --merge, --sidecar, --overwrite, and --resolve flags for
26
+ reconciling locally-modified files).
25
27
 
26
28
  Examples:
27
29
  bin/rails hyperdrive:init
@@ -2,6 +2,7 @@ require "rails/generators"
2
2
  require "rails/generators/base"
3
3
  require "json"
4
4
  require "rails/hyperdrive/companion_discovery"
5
+ require "rails/hyperdrive/install_layout"
5
6
  require "rails/hyperdrive/mcp_server"
6
7
  require "generators/hyperdrive/content_sync_support"
7
8
  require "generators/hyperdrive/gitignore_support"
@@ -22,12 +23,44 @@ module Rails
22
23
  GEMFILE = "Gemfile".freeze
23
24
  BUNDLER_PLUGIN = "bundler-hyperdrive".freeze
24
25
 
26
+ CONFIG_TEMPLATE = <<~YAML.freeze
27
+ # rails-hyperdrive settings. This file is yours: hyperdrive:init creates it
28
+ # once, and no hyperdrive command writes to it afterwards.
29
+ #
30
+ # disabled: artifacts never to install, by name, per kind. Naming a
31
+ # collision-postfixed variant (foo--gem_a) opts out that one source only.
32
+ # A disabled artifact already on disk is removed on the next init/sync
33
+ # unless you edited it.
34
+ # enabled: gems to scan as hyperdrive companions even though they do not
35
+ # opt in themselves; disabled: still wins per artifact.
36
+ # resolve: the command `hyperdrive:sync --resolve` hands each unresolved
37
+ # <file>.new sidecar to, git-mergetool style. Placeholders $LOCAL (the
38
+ # live file), $REMOTE (the sidecar), $BASE (the common ancestor, when
39
+ # one is available), $MERGED (the live file again — what the command
40
+ # must write), $SOURCE, $PREVIOUS_SOURCE, $KIND, and $PROMPT (the
41
+ # instructions to give an agent) are substituted per argument, and each
42
+ # is also exported as HYPERDRIVE_LOCAL, HYPERDRIVE_REMOTE, and so on.
43
+ # Exit 0 means resolved and the sidecar is deleted; any other exit
44
+ # leaves the file, the sidecar, and the lock untouched. prompt: names
45
+ # your own ERB template, relative to the app root, replacing the
46
+ # built-in one.
47
+ disabled:
48
+ skills: []
49
+ guidelines: []
50
+ agents: []
51
+ commands: []
52
+ enabled: []
53
+ # resolve:
54
+ # command: <your tool> $PROMPT
55
+ # prompt: .hyperdrive/resolve-prompt.md.erb
56
+ YAML
57
+
25
58
  # No templates are rendered; source_root exists so Rails resolves the
26
59
  # sibling USAGE file for `--help`.
27
60
  source_root File.expand_path("templates", __dir__)
28
61
 
29
62
  class_option :mount_at, type: :string, default: DEFAULT_MOUNT_AT, desc: "Engine mount path."
30
- class_option :skip_content, type: :boolean, default: false, desc: "Skip all .claude content, CLAUDE.md, and the lockfile; leave the .mcp.json, .gitignore, Gemfile, and mount steps."
63
+ class_option :skip_content, type: :boolean, default: false, desc: "Skip all .claude content, CLAUDE.md, the config file, and the lockfile; leave the .mcp.json, .gitignore, Gemfile, and mount steps."
31
64
  class_option :skip_mcp, type: :boolean, default: false, desc: "Skip MCP setup entirely; write no .mcp.json entry and no engine mount."
32
65
  class_option :dry_run, type: :boolean, default: false, desc: "Show what would change; write nothing."
33
66
 
@@ -101,6 +134,19 @@ module Rails
101
134
  inject_into_file routes_file, snippet, after: /Rails\.application\.routes\.draw do\s*\n/
102
135
  end
103
136
 
137
+ # The settings are the user's, so an existing file is never rewritten or
138
+ # reformatted.
139
+ def bootstrap_config
140
+ return if options[:skip_content]
141
+
142
+ path = ::Rails::Hyperdrive::InstallLayout::CONFIG_PATH
143
+ if File.exist?(::Rails.root.join(path))
144
+ say_status :identical, "#{path} (already present)", :blue
145
+ else
146
+ create_file path, CONFIG_TEMPLATE
147
+ end
148
+ end
149
+
104
150
  # `--skip-content` writes no lockfile either: the lock is a manifest of
105
151
  # installed content, and an empty one would assert "zero files is the
106
152
  # managed set". A later init or sync reconstructs the full state.
@@ -63,7 +63,7 @@ module Rails
63
63
  "#{count} #{noun}#{"s" unless count == 1}"
64
64
  end
65
65
 
66
- private_class_method :installed_counts, :group_by_source, :display_name, :quantify
66
+ private_class_method :installed_counts, :group_by_source, :display_name
67
67
  end
68
68
  end
69
69
  end
@@ -14,7 +14,10 @@ Description:
14
14
  mutually-exclusive strategies reconcile them with a new upstream:
15
15
  --merge three-way-merge the upstream change into the live file
16
16
  (clean merges only; anything else falls back to --sidecar,
17
- so no conflict marker ever lands in a live file)
17
+ so no conflict marker ever lands in a live file). Clean
18
+ means textually non-overlapping, not semantically correct,
19
+ so `git diff` after a --merge run is a review step, not a
20
+ formality.
18
21
  --sidecar write the new upstream body to <file>.new next to the live
19
22
  file, leaving the live file untouched
20
23
  --overwrite restore the live file to the gem-shipped content,
@@ -25,13 +28,22 @@ Description:
25
28
  upstream wholesale. Either way the next sync is quiet: the lock already
26
29
  records the delivered upstream, so the same version is never re-offered.
27
30
 
31
+ --resolve does that step for you, git-mergetool style: after delivery it
32
+ hands every unresolved <file>.new to the command in resolve.command in
33
+ .hyperdrive/config.yml and deletes the sidecar when that command exits 0.
34
+ On its own it delivers to sidecars; with --merge, git merges first and the
35
+ command takes the rest. With no command configured it stops before syncing
36
+ anything. A sidecar you edited yourself is never handed off.
37
+
28
38
  Touches no bootstrap artifact: .mcp.json, the engine mount, the Gemfile
29
- plugin directive, and .gitignore belong to hyperdrive:init and are left
30
- alone.
39
+ plugin directive, .gitignore, and .hyperdrive/config.yml belong to
40
+ hyperdrive:init and are left alone.
31
41
 
32
42
  Examples:
33
43
  bin/rails hyperdrive:sync
34
44
  bin/rails hyperdrive:sync --merge
35
45
  bin/rails hyperdrive:sync --sidecar
36
46
  bin/rails hyperdrive:sync --overwrite
47
+ bin/rails hyperdrive:sync --resolve
48
+ bin/rails hyperdrive:sync --merge --resolve
37
49
  bin/rails hyperdrive:sync --dry-run
@@ -18,9 +18,11 @@ module Rails
18
18
  class_option :merge, type: :boolean, default: false, desc: "Three-way-merge upstream changes into locally-modified files; falls back to sidecar delivery."
19
19
  class_option :sidecar, type: :boolean, default: false, desc: "Deliver upstream changes for locally-modified files to <file>.new sidecars."
20
20
  class_option :dry_run, type: :boolean, default: false, desc: "Show what would change; write nothing."
21
+ class_option :resolve, type: :boolean, default: false, desc: "Hand each unresolved <file>.new sidecar to the resolver command in .hyperdrive/config.yml; delivers to sidecars unless --merge is given."
21
22
 
22
23
  def verify_options
23
24
  chosen = %i[overwrite merge sidecar].select { |flag| options[flag] }
25
+ chosen << :resolve if options[:resolve] && options[:overwrite]
24
26
  return if chosen.size <= 1
25
27
  raise Thor::Error,
26
28
  "hyperdrive: #{chosen.map { |flag| "--#{flag}" }.join(" and ")} are mutually exclusive; pick one"
@@ -30,6 +32,10 @@ module Rails
30
32
  runner.verify_environment!
31
33
  end
32
34
 
35
+ def verify_resolver
36
+ runner.verify_resolver! if options[:resolve]
37
+ end
38
+
33
39
  def discover_artifacts
34
40
  runner.discover_artifacts
35
41
  end
@@ -38,12 +44,16 @@ module Rails
38
44
  mode =
39
45
  if options[:overwrite] then :overwrite
40
46
  elsif options[:merge] then :merge
41
- elsif options[:sidecar] then :sidecar
47
+ elsif options[:sidecar] || options[:resolve] then :sidecar
42
48
  else :preserve
43
49
  end
44
50
  runner.install(mode: mode)
45
51
  end
46
52
 
53
+ def resolve_sidecars
54
+ runner.resolve_sidecars(dry_run: options[:dry_run]) if options[:resolve]
55
+ end
56
+
47
57
  def print_summary
48
58
  say ""
49
59
  say_status :done, "hyperdrive synced", :green
@@ -1,9 +1,11 @@
1
1
  require "thor"
2
2
  require "rails/hyperdrive"
3
3
  require "rails/hyperdrive/bundler_artifact_discovery"
4
+ require "rails/hyperdrive/config_file"
4
5
  require "rails/hyperdrive/install_layout"
5
6
  require "rails/hyperdrive/install_pipeline"
6
7
  require "rails/hyperdrive/lock_file"
8
+ require "rails/hyperdrive/sidecar_resolver"
7
9
  require "generators/hyperdrive/install_summary"
8
10
 
9
11
  module Rails
@@ -13,6 +15,16 @@ module Rails
13
15
  # `install` forces the ones it needs, so no call order can install with a
14
16
  # half-built input set.
15
17
  class SyncRunner
18
+ RESOLVER_HELP = <<~MSG.freeze
19
+ --resolve needs a resolver command; add one to %<config>s:
20
+
21
+ resolve:
22
+ command: <your tool> $PROMPT
23
+
24
+ Placeholders: $LOCAL $REMOTE $BASE $MERGED $SOURCE $PREVIOUS_SOURCE $KIND $PROMPT
25
+ (also exported as HYPERDRIVE_* environment variables). Exit 0 marks the sidecar resolved.
26
+ MSG
27
+
16
28
  def initialize(shell:, root: nil)
17
29
  @shell = shell
18
30
  @root = root&.to_s
@@ -43,17 +55,58 @@ module Rails
43
55
  shell: @shell,
44
56
  artifacts: discover_artifacts,
45
57
  mode: mode,
46
- report: report
58
+ report: report,
59
+ config: config
47
60
  )
48
- @pipeline.call
61
+ @result = @pipeline.call
62
+ end
63
+
64
+ # Raised before any content write, so --resolve with nothing to run
65
+ # fails without half-syncing the app.
66
+ def verify_resolver!
67
+ return if config.resolve_command
68
+
69
+ raise Thor::Error,
70
+ "hyperdrive: " + format(RESOLVER_HELP, config: ::Rails::Hyperdrive::InstallLayout::CONFIG_PATH)
71
+ end
72
+
73
+ def resolve_sidecars(dry_run: false)
74
+ @resolve_outcome = ::Rails::Hyperdrive::SidecarResolver.new(
75
+ root: root,
76
+ shell: @shell,
77
+ command: config.resolve_command,
78
+ lock: @pipeline.lock,
79
+ sidecars: Array(@result&.sidecars),
80
+ prompt_path: config.resolve_prompt,
81
+ dry_run: dry_run
82
+ ).call
49
83
  end
50
84
 
51
85
  def summary_lines
52
- InstallSummary.lines(lock_entries)
86
+ InstallSummary.lines(lock_entries) + merge_lines + resolve_lines
53
87
  end
54
88
 
55
89
  private
56
90
 
91
+ def merge_lines
92
+ count = Array(@result&.merged).size
93
+ return [] if count.zero?
94
+
95
+ ["", " Merged #{InstallSummary.quantify(count, "file")} by three-way merge; a clean merge is " \
96
+ "textually non-overlapping, not verified; review with `git diff`"]
97
+ end
98
+
99
+ def resolve_lines
100
+ outcome = @resolve_outcome
101
+ return [] unless outcome
102
+
103
+ parts = { "resolved" => outcome.resolved.size, "unresolved" => outcome.unresolved.size,
104
+ "skipped" => outcome.skipped.size }.reject { |_label, count| count.zero? }
105
+ return ["", " No sidecars to resolve"] if parts.empty?
106
+
107
+ ["", " Sidecars: #{parts.map { |label, count| "#{count} #{label}" }.join(", ")}"]
108
+ end
109
+
57
110
  # Raised from install, so it stops the run before any content write —
58
111
  # a dry run included.
59
112
  def verify_lock_schema!
@@ -80,8 +133,14 @@ module Rails
80
133
  )
81
134
  end
82
135
 
136
+ def config
137
+ @config ||= ::Rails::Hyperdrive::ConfigFile.load(
138
+ File.join(root, ::Rails::Hyperdrive::InstallLayout::CONFIG_PATH)
139
+ )
140
+ end
141
+
83
142
  def enabled_gems
84
- lock.enabled_gems
143
+ config.enabled_gems
85
144
  end
86
145
 
87
146
  def lock_entries