diff --git a/.changepacks/changepack_log_skill_drift_autorefresh.json b/.changepacks/changepack_log_skill_drift_autorefresh.json
new file mode 100644
index 0000000..bc8102d
--- /dev/null
+++ b/.changepacks/changepack_log_skill_drift_autorefresh.json
@@ -0,0 +1,7 @@
+{
+ "changes": {
+ "crates/devup-mcp/Cargo.toml": "Patch"
+ },
+ "note": "The embedded devup-ui skill is re-vendored to the revision that now states the CSS rules, and a scheduled job keeps it that way from here. Two directions of drift exist and only one was covered. `cargo test` holds the manifest to the documents in this tree, so a hand-edited vendored copy fails on its digest; nothing checked whether those documents still match the repository they were copied from. That second drift is caused by another project publishing, so it arrives with no commit here and no pull request notices it - and the copies are exactly what `devup_skills install` writes on a machine with no network, which is the machine this whole feature exists for. This was not hypothetical: devup-ui's SKILL.md had already moved from b0d61a2d to 3b1c3bff, 16,629 to 20,632 bytes, adding the rule that no .css file belongs in application source and the rule for what devup-ui extracts statically. A bare machine installing from this binary would have been told neither. The new skill-drift job runs weekly and on demand, re-vendors, and opens a pull request carrying the refreshed bytes and a Patch changepack, rather than only turning a scheduled run red - a red scheduled run is a notification nobody acts on, and without the changepack the refresh would fail its own changepack-required gate and sit unreleased. It is deliberately not a pull request gate: failing unrelated pull requests over someone else's publish is the trade devup-ui made by pinning a dependency to latest beside a committed generated file, where every pull request against main went red for a staleness none of them caused.",
+ "date": "2026-09-15T11:10:00+09:00"
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 24dcfc3..249cb65 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,6 +23,13 @@ on:
push:
branches: [main]
pull_request:
+ # The vendored agent skills go stale when the repositories they were copied
+ # from move, which is a thing that happens on someone else's schedule and
+ # that no commit here will ever announce. `skill-drift` below is the only
+ # job these two triggers exist for.
+ schedule:
+ - cron: "0 6 * * 1"
+ workflow_dispatch:
permissions:
contents: write
@@ -93,6 +100,84 @@ jobs:
} >&2
exit 1
+ # `cargo test` already holds the manifest to the documents in this tree: a
+ # hand-edited vendored copy fails `every_origin_is_well_formed` on its digest.
+ # What nothing checked is the other direction — whether the copy still matches
+ # the repository it was taken from. That drift is caused by another project
+ # publishing, so it arrives without a commit here and no pull request notices.
+ #
+ # Deliberately NOT a pull request gate. The thing it reports is someone else's
+ # change, and failing unrelated pull requests over it is a known-bad trade:
+ # devup-ui pinned a dependency to `latest` next to a committed generated file
+ # and every pull request against main went red for a staleness none of them
+ # caused. A weekly run mails the owner and blocks nobody.
+ skill-drift:
+ name: refresh vendored skills
+ if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 24
+ - name: Re-vendor each skill from its source repository
+ # Authenticated so the GitHub API rate limit is per-repository rather
+ # than per-runner-IP, which unauthenticated scheduled runs share.
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ run: node scripts/refresh-skills.mjs
+ # A red scheduled run is a notification nobody acts on. A pull request is
+ # the same information in a form that can be reviewed and merged, and it
+ # carries the refreshed bytes so nobody has to reproduce them by hand.
+ - name: Open a pull request when a document moved
+ env:
+ GH_TOKEN: ${{ github.token }}
+ BRANCH: skills/refresh-vendored-documents
+ shell: bash
+ run: |
+ set -euo pipefail
+ if git diff --quiet; then
+ echo "Every vendored document already matches its source."
+ exit 0
+ fi
+
+ moved="$(git diff --name-only -- crates/devup-mcp/src/server/skills \
+ | grep -E '/SKILL\.md$' | sed 's#.*/skills/##; s#/SKILL\.md##' | paste -sd', ' -)"
+ echo "Moved upstream: ${moved:-none}"
+
+ # A re-vendored document changes what `devup_skills install` writes,
+ # so it is a shipped behaviour change and needs a version. Without
+ # this the `changepack required` gate fails this pull request, and
+ # the refresh would sit unreleased.
+ jq -n --arg moved "$moved" --arg date "$(date -u +%Y-%m-%dT%H:%M:%S+00:00)" '{
+ changes: { "crates/devup-mcp/Cargo.toml": "Patch" },
+ note: ("Re-vendored the embedded agent skill documents that moved in their source repositories: " + $moved + ". The copies in this binary are what `devup_skills install` writes on a machine with no network, so a copy that has fallen behind installs rules the upstream project no longer states. Opened automatically by the scheduled skill-drift job; the bytes here are exactly what scripts/refresh-skills.mjs produced."),
+ date: $date
+ }' > .changepacks/changepack_log_skill_refresh.json
+
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git checkout -B "$BRANCH"
+ git add crates/devup-mcp/src/server/skills .changepacks
+ git commit -m "chore(skills): re-vendor the documents that moved upstream" \
+ -m "Automated by the scheduled skill-drift job. Moved: ${moved:-none}."
+ # Force is safe and intended here: this branch is owned by this job
+ # and is rewritten from main on every run, so a stale refresh never
+ # accumulates on top of a newer one.
+ git push --force origin "$BRANCH"
+
+ if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')" ]; then
+ gh pr create --base main --head "$BRANCH" \
+ --title "chore(skills): re-vendor the documents that moved upstream" \
+ --body "The embedded copies of these skills fell behind their source repositories: **${moved:-none}**.
+
+ Those copies are what \`devup_skills install\` writes on a machine with no network, so a stale one installs rules the upstream project no longer states. \`cargo test\` cannot see this: it checks the manifest against the documents in this tree, which agree with each other while both are behind.
+
+ Produced by \`node scripts/refresh-skills.mjs\` in the scheduled \`skill-drift\` job. Review the diff as you would any skill change - it is upstream prose, not generated code."
+ else
+ echo "An open pull request already tracks this branch; it now carries the newer bytes."
+ fi
+
verify:
strategy:
matrix:
diff --git a/crates/devup-mcp/src/server/skills/devup-ui/SKILL.md b/crates/devup-mcp/src/server/skills/devup-ui/SKILL.md
index 7f3c800..13803ac 100644
--- a/crates/devup-mcp/src/server/skills/devup-ui/SKILL.md
+++ b/crates/devup-mcp/src/server/skills/devup-ui/SKILL.md
@@ -407,6 +407,17 @@ import DevupUI from "@devup-ui/vite-plugin";
export default defineConfig({ plugins: [react(), DevupUI()] });
```
+> **The Vite plugin does not mean the project is a Vite SPA.** `vinext` runs
+> Next.js App Router *on* Vite, so an App Router project has a `vite.config.ts`,
+> no `next.config.ts`, and uses this plugin. Decide from `package.json`: a
+> `vinext` or `next` dependency means file routing under `src/app/`, and there
+> is no `main.tsx` or `index.html` to create.
+
+```ts
+// vinext project - both plugins belong here
+plugins: [DevupUI(), vinext({ nextConfig: { output: "export" } })];
+```
+
### Next.js
```ts
@@ -451,6 +462,77 @@ DevupUI({
})
```
+## Never Author a CSS File
+
+Devup UI extracts styling at build time. A hand-written stylesheet is invisible
+to it: it cannot be checked, themed, or ordered in the cascade, and it competes
+silently with the classes the plugin generated.
+
+| Need | Use |
+|------|-----|
+| Reset / normalize | `resetCss()` from `@devup-ui/reset-css` |
+| Document-level rules (`body`, `*`, `@font-face`) | `globalCss({ ... })` |
+| Component styling | Style props, or `css({ ... })` |
+| A genuinely runtime value | A style prop - the plugin emits a CSS variable |
+
+The only acceptable CSS import is a stylesheet **shipped by an installed
+package you do not author**, such as an offline webfont package.
+
+### `@devup-ui/reset-css`
+
+It is a package, so the plugin has to be told to process it or its classes are
+never emitted. With Vite the two resolver settings are needed as well:
+
+```ts
+plugins: [DevupUI({ include: ["@devup-ui/reset-css"] })],
+optimizeDeps: { exclude: ["@devup-ui/reset-css"] },
+ssr: { noExternal: ["@devup-ui/reset-css"] },
+```
+
+## What Decides Static Extraction
+
+One rule explains `Dynamic Values = CSS Variables`, `$token Scope` and
+`Inline Variant Pattern` below:
+
+> Devup UI extracts at build time only what it can prove is constant **at the
+> JSX prop site**. A value reached through a variable is treated as possibly
+> mutated at runtime - TypeScript's types are not a runtime guarantee - so it
+> falls back to a CSS variable.
+
+| Form | Result |
+|------|--------|
+| `