feat(skills): version-pinned Avo skills shipped inside the gem - #4689
Conversation
…ader Skills previously lived in avo-hq/skills and were installed globally, with no relationship to the avo version an app had locked. They now ship inside this gem at lib/avo/skills/, so they describe the Avo the app actually runs. - 24 core skills, each stamped with metadata.avo-version and opening with the version/precedence line the agent reads. - lib/avo/skills/index.md — what the loader prints. - lib/avo/skills/package-map.md — allowlist and miss-path map. It gates which gems the loader will read skills from at all: `avo-` on rubygems.org is unreserved and loaded skill text becomes instructions, so a gem name alone must not earn that authority. - lib/avo/skills/bin/avo-skills-resolve — reads Gemfile.lock as the inventory and version oracle, resolves each gem's path, and asserts the resolved gem's own VERSION matches the lock before using it. Zeitwerk now ignores lib/avo/skills. Without it, avo-menu-icons/scripts makes its hyphenated directory eligible and boot dies with `Zeitwerk::NameError: wrong constant name Avo-menu-icons`. The loader does not trust `bundle show` alone: it exits 18 on an unrelated Ruby-version mismatch, and `gem which` returns a stale gem silently. Both are fallbacks behind the version assertion. avo-filters keeps the routing table and basic filters; the dynamic-filters and scopes sections move to their own gems and are reached by pointer. avo-menu-icons' fetch_icons.rb is replaced by list_icons.rb, which reads the Tabler names from the installed avo-icons gem. The old script wrote a cache file next to itself — inside an installed gem, which is frequently read-only — and needed a GitHub call to do it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`rails g avo:skills` installs one small loader into .claude/skills/avo, .agents/skills/avo, and .cursor/skills/avo. Only the loader is copied — the skills stay in the gem, which is the point: a copied skill tree drifts from the locked version with nothing to refresh it. The resolver has one source of truth (lib/avo/skills/bin/), and the generator stamps each copy with the gem version so the installed copy can tell the user when it has fallen behind. Copied rather than symlinked: a symlink into a version-named gem directory breaks on `bundle update`. Own namespace rather than folding into avo:install, so existing apps see no behavior change. Also excludes shipped skill assets from Avo::Reloader. It globs lib/avo/**/*.rb and `load`s each file in Avo development, so it executed the menu-icons helper against rspec's ARGV and aborted the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… path Packaging, integrity, and resolver specs (166 examples). Nothing in this gem asserted spec.files content before, so a packaging miss was invisible until after a release. Two bugs the specs found: - The resolver inherited BUNDLE_GEMFILE, so an agent invoked from inside any `bundle exec` context resolved that outer bundle's Avo instead of the app's. Bundler's ambient state is now cleared for both `bundle show` and `gem env`. - Three skills shipped descriptions over the Agent Skills spec's 1024-char cap (1178, 1614, 1071). Spec-compliant runtimes truncate or reject those, which silently kills triggering. Trimmed, keeping the trigger phrasings. Also adds spec/skills_eval — the corpus, rubric, and pass bar for the two failure modes no test here can observe: whether the pointer fires at all, and whether loaded text beats the model's Avo 3 priors. Both fail silently. The legacy catalog must not be removed until that table has a passing row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| # points an agent at something that is missing, misnamed, or describes a | ||
| # different Avo version than the one it shipped in. | ||
| RSpec.describe "shipped skills integrity" do | ||
| SKILLS_ROOT = Avo::Engine.root.join("lib", "avo", "skills") |
There was a problem hiding this comment.
[rubocop] reported by reviewdog 🐶
Lint/ConstantDefinitionInBlock: Do not define constants this way within a block.
| # different Avo version than the one it shipped in. | ||
| RSpec.describe "shipped skills integrity" do | ||
| SKILLS_ROOT = Avo::Engine.root.join("lib", "avo", "skills") | ||
| NON_SKILL_DIRS = %w[bin].freeze |
There was a problem hiding this comment.
[rubocop] reported by reviewdog 🐶
Lint/ConstantDefinitionInBlock: Do not define constants this way within a block.
| # a model preferring its Avo 3 priors over the loaded text. It is worth | ||
| # nothing if it silently goes missing during an edit. | ||
| it "opens with the version and precedence line" do | ||
| expect(body).to match(/\A\s*> \*\*These instructions ship inside avo #{Regexp.escape(Avo::VERSION)}/) |
There was a problem hiding this comment.
[rubocop] reported by reviewdog 🐶
[Corrected] Performance/ConstantRegexp: Extract this regexp into a constant, memoize it, or append an /o option to its options.
| # a state a real app reaches: a Ruby-version mismatch that breaks `bundle show`, | ||
| # a stale gem left behind by another install, a lock naming a gem that is gone. | ||
| RSpec.describe "avo-skills-resolve" do | ||
| RESOLVER = Avo::Engine.root.join("lib", "avo", "skills", "bin", "avo-skills-resolve").to_s |
There was a problem hiding this comment.
[rubocop] reported by reviewdog 🐶
Lint/ConstantDefinitionInBlock: Do not define constants this way within a block.
| # a stale gem left behind by another install, a lock naming a gem that is gone. | ||
| RSpec.describe "avo-skills-resolve" do | ||
| RESOLVER = Avo::Engine.root.join("lib", "avo", "skills", "bin", "avo-skills-resolve").to_s | ||
| SKILLS_SRC = Avo::Engine.root.join("lib", "avo", "skills") |
There was a problem hiding this comment.
[rubocop] reported by reviewdog 🐶
Lint/ConstantDefinitionInBlock: Do not define constants this way within a block.
avo-audit_logging declares VERSION with single quotes, so matching only double quotes would have failed the version assertion on a healthy gem and dropped its skills from the index. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The avo-hq/skills validator requires metadata.requires-gem on every skill, and the loader genuinely does require avo — resolving that gem is its whole job. Keeps the deprecated repo's copy byte-identical to this template. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lint/ConstantDefinitionInBlock: hoist the spec constants out of the describe blocks, and use let for the two the resolver spec only needs at example time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…map tables The loader reads the map positionally to describe an absent gem, so the two tables must agree on where the subject lives. They did not, and the two pointer-only gems printed their 'named by' column instead of what they actually unlock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
spec/features/avo/generators/namespaced_resource_generator_spec.rb generates the galaxy/planet/satellite resources and controllers, then calls check_files_and_clean_up, which deletes them. Those four files are checked-in dummy-app fixtures, so running that spec leaves the tree with four deletions — and my `git add -A` swept them into a commit. Nothing in this PR touches the dummy app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ery turn The loader used to carry a "remove any older global install" section, which made every agent session re-check something that only matters once. The generator now finds the leftovers and offers to remove them. `rails g avo:skills` reports what a pre-gem avo-hq/skills install left behind and asks before deleting. `--clean-legacy` / `--no-clean-legacy` skip the prompt for scripted runs; quiet mode never prompts and never deletes. Two safety limits: - Only directories containing a SKILL.md are eligible. A `rm -rf` driven by a name glob alone would take an unrelated `avo-notes/` with it. - ~/.claude/skills is reported, never deleted. It is shared by every project on the machine, so a generator run inside one app must not remove skills another app may still want — and no test suite should be able to wipe a real home directory. A spec pins that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same cause as before: the namespaced resource generator spec deletes these four checked-in files as part of its cleanup, and `git add -A` picked the deletions up. Verifying with `git diff main --stat -- spec/dummy/` before committing, not just `git status`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er skill
The full avo-hq/skills catalog is 35 skills, and `npx skills add`
materializes it into every scan path — so a real install is ~105
directories and the prompt was buried under a hundred lines.
Reports one line per directory with a count instead:
Found 105 skills in this project from a previous avo-hq/skills install:
.agents/skills/ 35
.claude/skills/ 35
.cursor/skills/ 35
Removal and the leave-them-alone command collapse the same way — one
`rm -rf <dir>/avo-*` glob per directory rather than 105 pasted paths.
The reporting examples now run against their own temp root. They were
counting fixtures other examples had left in the shared dummy app, which
made them pass alone and fail in the suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
metadata.avo-version was a third copy of a fact the gem already carries. The file lives inside gems/avo-<version>/, version.rb declares it, and the resolver reads Gemfile.lock and asserts against version.rb before an agent opens any skill. A copy that has to be kept in sync by hand is precisely the drift this feature exists to remove — automating the sync in `ws release` treated the symptom. The precedence line stays; it does the real work. It just no longer repeats the digits, which the resolver has already printed alongside the verified path by the time the agent reads a skill. Removes the stamp from all 24 core skills, its integrity checks, and the release-time re-stamp machinery. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three core skills carried content describing avo-menu's and avo-advanced_search's APIs. That content was pinned to *avo's* version, not to the version of the gem it describes — if either gem changes its DSL while avo stays put, the shipped instructions are wrong and nothing detects it. That is the drift this whole feature exists to remove. The add-on content was also split across two core skills each, so this consolidates rather than carving three times: - avo-menu gets one skill: the menu editor DSL, absorbing avo-navigation-search's `## Menus` and avo-menu-icons' Approach A. - avo-advanced_search gets one: Cmd+K global search plus searchable association pickers, from avo-navigation-search and avo-associations. The dependency now runs the safe direction. A gem's skill may reference core's, because core is always installed; core referencing a gem is the conditional case, and what is left in core are pointers the loader resolves — or names as missing. avo-menu-icons' concept-to-icon table is hoisted ahead of both approaches. It is Community, both approaches used it, and it was sitting inside the half that moved. list_icons.rb stays in core: it reads avo-icons, which is avo's dependency, not avo-menu's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
avo-aware exists to fire on Rails-shaped requests that never mention Avo — "add a status column to Project", "let admins approve orders". Under the loader that was impossible: it sat inside the gem, reachable only once something had already decided Avo was relevant. Its entire reason to exist was unreachable. The trigger logic moves into the pointer, which is the only thing always in context: a widened description covering model-change requests, the gate that confirms this app actually uses Avo (most Rails apps do not, and misfiring on them is worse than missing), and do-Rails-first / propose-before-writing. avo-aware keeps what is genuinely version-pinned and has no business in a copied file — the Rails-change-to-admin-surface routing table and the column-type-to-field mapping, which changes as Avo adds field types. 144 lines down to 74. Description is 850 chars, inside the 1024 cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"usually one to three" was an arbitrary cap I wrote into the pointer, the index, and the resolver's footer. A task that spans resources, fields, associations, filters and actions legitimately needs all of them, and a number in the instructions is exactly the kind of thing a model treats as a budget — stopping short and guessing at the part it skipped. The rule that actually matters is relevance: read every skill the task touches, skip the ones it does not, never read the whole set by reflex. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…te avo Skills now ship in fifteen gems, not one. `bundle update avo` bumps core and leaves every add-on's skills on whatever version they were already pinned to — silently, and in the direction that reintroduces drift. `avo:update` resolves avo plus every registered plugin to its real gem name and updates them together with --conservative, which is exactly the guarantee this feature needs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14/15 trigger (93%, bar 90%), 0/4 false triggers (bar 0). The one miss is a corpus defect: 'make this board drag-and-drop' in an app with no board, and the agent correctly asked which one instead of guessing. Prior-conflict prompts all fired but the diffs are unscored — that question needs a reader who did not write the skills. Also documents the trap that cost the first attempt: claude -p denies Bash by default, so the loader triggers and then cannot run the resolver, which in the log is indistinguishable from never triggering at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Maintainer reviewed the three diffs. All followed the shipped instructions over model priors, with checkable fingerprints in each: the range-split gotcha from avo-filters, the named-missing-gem branch, and list_icons.rb catching a hallucinated icon name before it reached a file. The eval gate on removing the legacy catalog is now met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MIN_SKILLS_VERSION was a guess at an unreleased version number, baked into a file that gets copied into the user's app and therefore cannot be corrected if the release lands differently. The error still needs to exist — the deprecated avo-hq/skills channel hands the pointer to everyone, including apps whose Avo predates gem-shipped skills, and `rails g avo:skills` is not reachable from those versions. It now names the version it resolved and points at `bin/rails avo:update` rather than a number nobody can update later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I had claimed a global install could not work because "one machine would carry one copy across projects on different Avo versions." That was wrong. The loader holds no version knowledge: it finds the app by walking up from the working directory at run time and resolves that app's Gemfile.lock. One copy serves four projects on four versions, each correctly. Verified directly — a single copy in a fake home, two apps pinned to 4.1.0 and 4.3.7, each resolving to its own gem. Two details make it behave: - The global copy is left unstamped. The staleness check compares the stamp against the resolved gem, which is meaningful for one app and pure noise across four; the resolver already treats the unstamped sentinel as "do not warn". - Legacy cleanup follows the install target. A project install still must not delete from the shared home directory, but a --global install is the one case where home is exactly what the user means. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
STAMP_VERSION compared the installed copy's version against the resolved gem's, so it warned after every routine `avo:update` — even when the resolver itself had not changed by a byte. A warning that fires on every upgrade stops being read, which is worse than not having one. It now compares itself against the gem's copy with `cmp`. That is the condition anyone actually cares about: does this copy still behave like the one the gem ships. Verified — silent across a 4.1.0 to 4.9.9 bump with an unchanged resolver, warns the moment the copy is edited. Deletes the constant, the stamping in the generator, and the --global carve-out that existed only because a stamped global copy would report itself stale in every project but one. The resolver is now installed verbatim everywhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Speculation with zero callers — nothing in the pointer skill or any shipped skill ever passed it, so the index was always printed anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
This PR has been merged into Please check the release guide for more information. |
Closes AVO-1577, AVO-1578. Part of AVO-1576.
The problem
Avo's ~35 agent skills live in avo-hq/skills and are installed globally —
npx skills add, a plugin marketplace, or symlinks into~/.claude/skills/. The installed copy has no relationship to theavoversion an app has locked. A developer with Avo 4 in one project and Avo 3 in another gets the same instructions in both, and neither the tool nor the user is told which one is wrong.What this does
Skills ship inside this gem at
lib/avo/skills/, so they describe the Avo actually running.rails g avo:skillsinstalls one small loader; the skills themselves are never copied, because a copied tree is what drifts.lib/avo/skills/bin/avo-skills-resolve— readsGemfile.lockas the inventory and version oracle, resolves each gem's path, and asserts the resolved gem's ownVERSIONmatches the lock before using it.package-map.md— allowlist and miss-path map.avo:skillsgenerator — installs the loader to.claude/,.agents/, and.cursor/, and offers to remove skills left by a pre-gem install.lib/**/*was already inspec.files, so this ships with zero gemspec changes.Things worth a reviewer's attention
Zeitwerk would have broken boot in every host app.
lib/avo.rbbuildsZeitwerk::Loader.for_gemoverlib/and eager-loads under Rails. A.rbunder a hyphenated skill directory makes that directory eligible and Zeitwerk raiseswrong constant name Avo-menu-icons. Reproduced against zeitwerk 2.8.2, fixed withloader.ignore, guarded by a spec assertingAvo::Skillsnever comes into being.Avo::Reloaderwas a second, separate loader. It globslib/avo/**/*.rbandloads each file in Avo development — it executed the menu-icons helper against rspec's ARGV and aborted the suite.bundle showis not trustworthy alone. Indemo_apps/main.avodemo.comit exits 18 with a Ruby-version message that never mentions avo, andbundle info --pathwrites that sentence to stdout. The obvious fallback,gem which avo, returns a build 24 patch releases stale. Hence: lock is the oracle,bundle showis one fallback among several, nothing is used unverified.The loader inherited
BUNDLE_GEMFILE, so an agent invoked from inside anybundle execresolved the outer bundle's Avo. Found by the spec.avo-on rubygems.org is unreserved. Whatever the loader prints becomes instructions the agent follows, so discovery is gated on the shipped allowlist. A spec plants a squattedavo-evilgem with a skills tree and asserts its content never appears.avo-awarecould never have fired. Its entire job is triggering on Rails-shaped requests that never mention Avo — but behind a loader gated on Avo being mentioned, it was unreachable. The trigger logic and the "does this app even use Avo" gate moved into the pointer, which is the only thing always in context;avo-awarekeeps the version-dependent routing table and column-type-to-field mapping.Four skills exceeded the spec's 1024-char
descriptioncap (up to 1614). Spec-compliant runtimes truncate or drop those, which silently kills triggering.fetch_icons.rbreplaced bylist_icons.rb. The old script wrote a cache file next to itself — inside an installed gem, frequently read-only — after a GitHub API call. The Tabler names are already on disk inavo-icons, a hard dependency. Its frontmatter also omittedBashwhile instructing a shell command, and used a path relative to the app's cwd.No per-skill version stamp. An earlier revision stamped each skill with the gem version; it was removed. The file already lives inside
gems/avo-<version>/,version.rbdeclares it, and the resolver asserts againstGemfile.lock— a fourth hand-synced copy could only drift.Not in this PR
Package-owned skills for the feature gems,
ws audit-skills, the docs rewrite, and deprecatingavo-hq/skills. This must merge and release first — a feature gem's skills are inert until core knows how to look for them.Verification
The resolver's failure branches are covered as fixtures: version mismatch, missing lock, unlocked avo, gem absent from disk, pre-skills Avo version, hostile gem name, non-allowlisted gem, single-quoted
VERSION, and credential hygiene across stdout+stderr.spec/skills_eval/carries the corpus and pass bar for the two things no test here can observe — whether the pointer triggers, and whether loaded text beats the model's priors.🤖 Generated with Claude Code