Skip to content

fix(skills): install where the runtime actually reads, and stop installed meaning is_file() - #74

Merged
owjs3901 merged 3 commits into
mainfrom
owjs3901/skills-reach-the-runtime-that-asked
Sep 22, 2026
Merged

owjs3901 merged 3 commits into
mainfrom
owjs3901/skills-reach-the-runtime-that-asked

Conversation

@owjs3901

@owjs3901 owjs3901 commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

A skill installed into a directory the connected runtime never opens reports success, sits on disk, and still never loads. That silent failure is what the reports from Codex were.

The cause

Codex reads project skills from .agents/skills and does not look at .claude/skills (ext/skills/src/host_roots.rs). A fresh project has no skill root at all, so the choice fell through to the first known convention ??.claude/skills ??and the agent was told devup-ui was installed while it went on writing devup-ui from guesses.

opencode never reported it because opencode scans .claude/skills for compatibility and so happened to work.

What changes

The runtime is read from the MCP clientInfo name sent at initialize, and decides the directory.

runtime project machine-wide (read only)
Claude Code .claude/skills ~/.claude/skills
Codex .agents/skills ~/.agents/skills, ~/.codex/skills
opencode .opencode/skill (+ the other two, for compatibility) ~/.config/opencode/skill (+ compat)
unnamed client all three all

A client that does not name itself gets every convention written. With no name and no existing root there is nothing to choose on, and picking one of three blind is a two-in-three chance of writing where nothing looks, against a cost of a few kilobytes inside a directory the project already owns.

Install state now also reads the machine-wide roots, because a skill sitting there really is loaded. A workspace holding devup-ui in ~/.claude/skills, ~/.codex/skills, ~/.config/opencode/skill and ~/.agents/skills ??every one of them live ??was answering installedCount: 0. The README itself calls telling someone to install what they already have the noise that teaches them to ignore the field. Those roots are read and never written, so the allowed-write-root boundary is unchanged.

The symmetric mistake is refused too, and it is the quieter one: a file in a directory this runtime never opens is not counted as installed.

devup_skills takes projectRoot like every other tool. Without it the report described the server's own write root, which is not the project unless the two were configured alike ??a host granting one shared parent (an Orca worktree pool, a monorepo checkout) had every call reporting on that parent and would have installed there.

Where the nudge lives now

The strongest sentence used to live on devup_skills's own description, which is the tool an agent has no reason to call.

  1. devup_figma_export's description. The one channel that reaches an agent which never sees the server instructions ??a subagent handed tool schemas alone, and Codex, where instructions becomes a single namespace description rather than prompt text.
  2. skillGap on the export response. Deliberately not gated on projectRoot, because the caller who does not know devup-ui is precisely the caller who does not send it.
  3. skillGap on devup_ui_validate. Unchanged; it covers the case where the code was already written and refused.

Verified by using it

The release binary driven over stdio as four named clients:

codex               ??.agents/skills   ?? (not .claude/skills)
claude-code         ??.claude/skills   ??opencode            ??.opencode/skill  ??some-unknown-editor ??all three        ??```

A skill placed only in `~/.codex/skills` came back `installed: true, action: null`.

A real `devup_figma_export` on a scratch home, against the local call bank ??one Figma read, nothing spent beyond what was already banked:

status : complete
tsx first line : import { Grid, Image } from "@devup-ui/react";

skillGap present : true
runtime : codex
missing : devup-ui, devfive-frontend
writesTo[0] : .agents\skills\devup-ui\SKILL.md

--- one devup_skills install later ---
re-export : complete, figma calls 0
skillGap present : false


## Notes

Install state resolves through one `Lookup` carrying project, home and runtime rather than three threaded parameters. That also makes the home injectable: reading the real one had these assertions passing in CI and failing on the machine of anyone with devup-ui installed in their own home, which is everyone working on this repository. The integration harness points the spawned server's home at a scratch directory for the same reason.

The new export tests live in their own file because `tests/module_boundaries.rs` holds the router to containing no `std::fs::` at all, and they touch the filesystem.

## Gates

`cargo fmt --all -- --check` 쨌 `cargo clippy --workspace --all-targets --all-features -- -D warnings` 쨌 `cargo test --workspace --all-features` 쨌 `cargo insta test --workspace --all-features --check` 쨌 `cargo test -p devup-mcp --test stdio_smoke` 쨌 `node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs` 쨌 `cargo build --workspace --release` ??all exit 0. 11 new tests.

---

# Second commit: `installed` stops meaning `is_file()`

The first commit fixed *absent*. This one fixes *present but wrong*, which is the other half of the same report — an agent can hold a `SKILL.md` and still be working from rules this binary no longer emits.

## The cause

`installed` was decided by whether a file existed. A `SKILL.md` written by a devup-mcp from six months ago was indistinguishable from the current document, and so was a one-line placeholder someone dropped in that directory.

The sting was on the other side of the same check: **`install` skipped anything already present, so there was no way to update a skill at all.** The first install a machine ever did was the last one it would get.

## Install state is now the bytes, not the file

| `installState.revision` | meaning | what `install` does |
|---|---|---|
| `current` | identical to what this build writes, or fetched from upstream (at least as new as the vendored copy, and not comparable further offline) | nothing |
| `older` | carries devup-mcp provenance, but is not this build's | **rewrites in place** |
| `incomplete` | the entry document is this build's, but a file it links to is missing or differs — looks installed, links go nowhere | **rewrites** |
| `foreign` | no devup-mcp provenance note at all | **left alone**, reported under `leftAlone` |

`foreign` is never overwritten because replacing a document this server did not write would destroy someone's work, and an install is not the moment to decide that was a mistake.

`report` counts `outdated` apart from `missing`: one is a skill the agent has never seen, the other is one it is reading right now.

## Twelve runtimes, and the one that justifies the table

Added from each vendor's own source or documentation: **Cursor**, **Zed**, **Windsurf**, **VS Code/Copilot**, **Gemini CLI**, **Cline**, **Continue**, **Amp**, **Goose**.

**Cline is why this is a table and not a constant.** It reads `.agents/skills` only from the home directory, so the shared project-local convention is one directory it never opens — and the previous fallback wrote exactly there.

**JetBrains AI Assistant is deliberately absent.** It has no auto-discovered directory; a human must register one in Settings. Guessing a path would be worse than the fallback.

An unidentified client now **reads every convention while still writing only the three broad ones**. Reading narrowly was telling a Cursor user who had installed once into `~/.cursor/skills` that the skill was missing, with a second copy as the remedy.

## Three more places the gap is named

- **`devup_project_context`** carries it too, scoped to what each scope actually found. `devup.json` is devup-ui's theme format, scope `api`'s `openapi.json` is generated by vespera routes, scope `db`'s `models/*.json` are vespertide schemas. A project with no `openapi.json` is told nothing about vespera.
- **`devup_figma_export`'s description** gained the three guide rules whose violation produces *wrong code* rather than a worse response: screenshots verify rather than author, do not hand-interpret the node tree, never guess a UI value. They lived only in a resource Codex collapses into one namespace description.
- **`report.machineWide`** hands over this runtime's home skill directory as a path to run yourself. The home directory stays outside the allowed write root — widening that so a design-to-code server can write to `$HOME` is not a trade worth making for convenience.

Plus `autoUpdate.installedPathNote`: a version-named install directory is the host's and fixed at install time, so `devup-mcp-0.9.0-dev/` around a binary reporting `0.10.1` is self-update working, not a mismatch.

## Verified by using it

Against the release binary:

--- a stale skill ---
revision : older report.outdated : ["devfive-frontend"]
refreshed : devfive-frontend
bytes restored : true revision after : current

--- a document devup-mcp did not write ---
revision : foreign leftAlone : devup-ui
still mine : true

--- a reference deleted ---
revision : incomplete -> install -> current

--- devup_project_context ---
theme -> devup-ui api -> vespera db -> (none, no models/*.json)

--- runtimes ---
cursor-vscode -> cursor .cursor/skills
Zed -> zed .agents/skills
gemini-cli-mcp-client -> gemini-cli .gemini/skills
@cline/core -> cline .cline/skills
Visual Studio Code -> vscode .github/skills
JetBrains-IU-copilot-intellij -> unknown all three conventions


A unit test caught the hazard the short names carry: `analyzed-client` contains `zed-` and matched Zed until the check was anchored.

Gates green again, 378 lib tests.

…say so on the response that carries the code

A skill installed into a directory the connected runtime never opens reports
success, sits on disk, and still never loads. That silent failure is what the
reports from Codex were.

Codex reads project skills from `.agents/skills` and does not look at
`.claude/skills`. A fresh project has no skill root at all, so the choice fell
through to the first known convention - `.claude/skills` - and the agent was
told devup-ui was installed while it went on writing devup-ui from guesses.
opencode never reported it because opencode scans `.claude/skills` for
compatibility and so happened to work.

The runtime is now read from the MCP `clientInfo` name sent at initialize and
decides the directory:

  claude-code  ->  .claude/skills
  codex        ->  .agents/skills
  opencode     ->  .opencode/skill   (+ the other two, read for compatibility)
  unnamed      ->  all three

A client that does not name itself gets every convention written. With no name
and no existing root there is nothing to choose on, and picking one of three
blind is a two-in-three chance of writing where nothing looks, against a cost
of a few kilobytes inside a directory the project already owns.

Install state now also reads the machine-wide roots - `~/.claude/skills`,
`~/.codex/skills`, `~/.config/opencode/skill`, `~/.agents/skills` - because a
skill sitting there really is loaded. A workspace holding devup-ui in all four,
every one of them live, was answering `installedCount: 0`; the README itself
calls telling someone to install what they already have the noise that teaches
them to ignore the field. Those roots are read and never written, so the
allowed-write-root boundary is unchanged.

The symmetric mistake is refused too, and it is the quieter one: a file in a
directory this runtime never opens is not counted as installed.

`devup_skills` takes `projectRoot` like every other tool. Without it the report
described the server's own write root, which is not the project unless the two
were configured alike - a host granting one shared parent, an Orca worktree
pool or a monorepo checkout, had every call reporting on that parent and would
have installed there.

The inducement moved to where agents actually read. The strongest sentence used
to live on `devup_skills`'s own description, which is the tool an agent has no
reason to call. It is now in `devup_figma_export`'s description, the one channel
that reaches an agent which never sees the server `instructions`: a subagent
handed tool schemas alone, and Codex, where `instructions` becomes a single
namespace description rather than prompt text. The same gap rides the export
response as `skillGap`, deliberately not gated on `projectRoot`, because the
caller who does not know devup-ui is precisely the caller who does not send it.
`devup_ui_validate` keeps its own copy for the case where the code was already
written and refused.

Install state resolves through one `Lookup` carrying project, home and runtime
rather than three threaded parameters. That also makes the home injectable:
reading the real one had these assertions passing in CI and failing on the
machine of anyone with devup-ui installed in their own home, which is everyone
working on this repository. The integration harness points the spawned server's
home at a scratch directory for the same reason.

Verified by using it. The release binary was driven over stdio as four named
clients: codex received `.agents/skills`, claude-code `.claude/skills`,
opencode `.opencode/skill`, and an unnamed client all three. A skill placed
only in `~/.codex/skills` came back installed with `action: null`. A real
`devup_figma_export` against the local call bank - one Figma read, none spent
beyond what was already banked - returned `status: complete` with real
devup-ui TSX and a `skillGap` naming devup-ui and devfive-frontend, writing to
`.agents/skills`; one `devup_skills install` closed it, and re-projecting the
same artifact came back with no gap and zero further Figma calls.
…the update path it never was

`installed` was decided by whether a file existed, so a `SKILL.md` written by
a devup-mcp from six months ago was indistinguishable from the current
document, and so was a one-line placeholder someone dropped in that directory.

The sting was on the other side of the same check. `install` skipped anything
already present, which meant there was no way to update a skill at all: the
first install a machine ever did was the last one it would get, and every
later call reported that stale copy as installed and current.

Install state now compares the bytes on disk against exactly what this build
would write, and answers with `installState.revision`:

  current      identical, or fetched from upstream - which is at least as new
               as the vendored copy and cannot be compared further offline
  older        carries devup-mcp provenance, but is not this build's
  incomplete   the entry document is this build's, but a file it links to is
               missing or differs - the shape that looks installed and whose
               links go nowhere
  foreign      no devup-mcp provenance note at all

An install rewrites `older` and `incomplete` in place, at the roots where the
stale copies actually are rather than where a fresh install would have chosen,
and never touches `foreign`. Replacing a document this server did not write
would destroy someone's work, and an install is not the moment to decide that
was a mistake; it is reported under `leftAlone` instead.

`report` counts `outdated` apart from `missing`, because the two need
different words from the caller: one is a skill the agent has never seen, the
other is one it is reading right now and being taught rules this binary no
longer emits.

The runtime table grew from three clients to twelve - Cursor, Zed, Windsurf,
VS Code/Copilot, Gemini CLI, Cline, Continue, Amp and Goose, each from its own
source or official documentation. Cline is the entry that justifies the table:
it reads `.agents/skills` only from the home directory, so the shared
project-local convention is one directory it never opens, and the previous
fallback wrote exactly there. JetBrains AI Assistant is deliberately absent -
it has no auto-discovered directory and a human must register one, so guessing
a path would be worse than the fallback.

An unidentified client now reads every convention devup-mcp knows while still
writing only the three broad ones. Reading narrowly was telling a Cursor user
who had installed once into `~/.cursor/skills` that the skill was missing,
with a second copy as the remedy.

`devup_project_context` carries the same gap, scoped to what each scope
actually found. `devup.json` is devup-ui's theme format, the `openapi.json`
under scope api is generated by vespera routes, and the `models/*.json` under
scope db are vespertide schemas - an agent about to edit one had no way to
learn it needed those rules. A project with no `openapi.json` is told nothing
about vespera.

Three guide rules moved into `devup_figma_export`'s own description:
screenshots verify rather than author, do not hand-interpret the node tree,
never guess a UI value. Those three produce wrong code rather than a worse
response when violated, and they lived only in a resource that Codex collapses
into a single namespace description.

`report` also hands over this runtime's machine-wide skill directory under
`machineWide`, as a path to run yourself. The home directory stays outside the
allowed write root; widening that so a design-to-code server can write to
`$HOME` is not a trade worth making for convenience.

`autoUpdate.installedPathNote` explains that a version-named install directory
belongs to the host and is fixed at install time, so a directory reading
0.9.0-dev around a binary reporting 0.10.1 is self-update working rather than
a mismatch.

Verified by using it. Against the release binary: a stale copy came back
`revision: older` with `outdated: ["devfive-frontend"]`, and one install
restored this build's bytes and reported `current`; a hand-written document
came back `foreign`, was listed under `leftAlone`, and was still byte-identical
afterwards; deleting one reference produced `incomplete` and the next install
repaired it. `devup_project_context` named devup-ui for a project with a
devup.json, vespera for one with an openapi.json, and nothing for db with no
models. `cursor-vscode` resolved to Cursor rather than VS Code and chose
`.cursor/skills`, Zed chose `.agents/skills`, Gemini `.gemini/skills`,
`@cline/core` `.cline/skills`, `Visual Studio Code` `.github/skills`, and
`JetBrains-IU-copilot-intellij` correctly fell through to all three
conventions.

A unit test caught the hazard the short names carry: `analyzed-client`
contains `zed-`, and matched Zed until the check was anchored.
Half the runtime table is matched against client names observed in the wild
rather than read out of source, because those clients are closed. A wrong
guess there does not fail: the match never fires, the runtime reads
`unknown`, every convention is written, and the result is safe and
completely silent - indistinguishable from a client devup-mcp has genuinely
never heard of.

The report now echoes the name verbatim as `runtime.clientName` beside the
verdict drawn from it. When nothing matched it also says what `unknown`
means: that it is safe rather than wrong, and that a client with its own
skill directory whose name is missing from the table is exactly what should
be reported. That is the only path by which the table gets corrected, and the
gap it closes is the same class of silent failure as the rest of this branch.

`Lookup::new` takes the client name and derives the runtime from it rather
than being handed a verdict, so the name and the conclusion cannot disagree.

Verified against the release binary: `codex` and `cursor-vscode` come back
recognised with their names echoed and no note; an unrecognised name comes
back `unknown` with the name intact and the note explaining what to do.
@owjs3901 owjs3901 changed the title fix(skills): install where the connected runtime actually reads, and say so on the response that carries the code fix(skills): install where the runtime actually reads, and stop installed meaning is_file() Sep 22, 2026
@owjs3901
owjs3901 merged commit 78ae3aa into main Sep 22, 2026
9 checks passed
@owjs3901
owjs3901 deleted the owjs3901/skills-reach-the-runtime-that-asked branch September 22, 2026 15:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant