Skip to content

feat(skills): version-pinned Avo skills shipped inside the gem - #4689

Merged
Paul-Bob merged 22 commits into
mainfrom
avo-1576/feature/version-pinned-skills-loader
Aug 4, 2026
Merged

feat(skills): version-pinned Avo skills shipped inside the gem#4689
Paul-Bob merged 22 commits into
mainfrom
avo-1576/feature/version-pinned-skills-loader

Conversation

@Paul-Bob

@Paul-Bob Paul-Bob commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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 the avo version 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:skills installs one small loader; the skills themselves are never copied, because a copied tree is what drifts.

  • 23 core skills, each opening with the precedence line the agent reads.
  • 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.
  • package-map.md — allowlist and miss-path map.
  • avo:skills generator — installs the loader to .claude/, .agents/, and .cursor/, and offers to remove skills left by a pre-gem install.

lib/**/* was already in spec.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.rb builds Zeitwerk::Loader.for_gem over lib/ and eager-loads under Rails. A .rb under a hyphenated skill directory makes that directory eligible and Zeitwerk raises wrong constant name Avo-menu-icons. Reproduced against zeitwerk 2.8.2, fixed with loader.ignore, guarded by a spec asserting Avo::Skills never comes into being.

Avo::Reloader was a second, separate loader. It globs lib/avo/**/*.rb and loads each file in Avo development — it executed the menu-icons helper against rspec's ARGV and aborted the suite.

bundle show is not trustworthy alone. In demo_apps/main.avodemo.com it exits 18 with a Ruby-version message that never mentions avo, and bundle info --path writes that sentence to stdout. The obvious fallback, gem which avo, returns a build 24 patch releases stale. Hence: lock is the oracle, bundle show is one fallback among several, nothing is used unverified.

The loader inherited BUNDLE_GEMFILE, so an agent invoked from inside any bundle exec resolved 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 squatted avo-evil gem with a skills tree and asserts its content never appears.

avo-aware could 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-aware keeps the version-dependent routing table and column-type-to-field mapping.

Four skills exceeded the spec's 1024-char description cap (up to 1614). Spec-compliant runtimes truncate or drop those, which silently kills triggering.

fetch_icons.rb replaced by list_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 in avo-icons, a hard dependency. Its frontmatter also omitted Bash while 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.rb declares it, and the resolver asserts against Gemfile.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 deprecating avo-hq/skills. This must merge and release first — a feature gem's skills are inert until core knows how to look for them.

Verification

bundle exec rspec spec/lib spec/features/avo/generators

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

Paul-Bob and others added 3 commits July 31, 2026 18:23
…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>
@linear-code

linear-code Bot commented Jul 31, 2026

Copy link
Copy Markdown

AVO-1577

AVO-1578

AVO-1576

# 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)}/)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[rubocop] reported by reviewdog 🐶
[Corrected] Performance/ConstantRegexp: Extract this regexp into a constant, memoize it, or append an /o option to its options.

Comment thread spec/lib/avo/skills/resolver_spec.rb Outdated
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[rubocop] reported by reviewdog 🐶
Lint/ConstantDefinitionInBlock: Do not define constants this way within a block.

Comment thread spec/lib/avo/skills/resolver_spec.rb Outdated
# 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>
Paul-Bob and others added 14 commits July 31, 2026 18:46
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>
Paul-Bob and others added 3 commits August 3, 2026 15:28
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>
@Paul-Bob
Paul-Bob merged commit 5eaf8ed into main Aug 4, 2026
24 checks passed
@Paul-Bob
Paul-Bob deleted the avo-1576/feature/version-pinned-skills-loader branch August 4, 2026 10:54
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

This PR has been merged into main. The functionality will be available in the next release.

Please check the release guide for more information.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant