Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ The filter for skilld-scoped work, precise enough to hold a proposal next to it

### 1. Provenance is the quality signal

A Skill is trustworthy because you can see who wrote it and read it before you run it. Every surface that shows a Skill names the author. The Skill page links the exact source file in its repository; other surfaces link the Skill page, which is where a visitor inspects a Skill. Registry identity is frozen at admission (ADR-0002) so provenance URLs never rot. Machine-generated metadata is labeled as such and never dressed as authorship.
A Skill is trustworthy because you can see who wrote it and read it before you run it. Every surface that shows a Skill names the author. The Skill page links the exact source file in its repository; other surfaces link the Skill page, which is where a visitor inspects a Skill. When GitHub moves a Repository, its Skills move to the new name and every old URL answers 301 (ADR-0015), so provenance URLs never rot. Machine-generated metadata is labeled as such and never dressed as authorship.

**How to apply:** no Skill card, search result, or digest entry ships without its author and a link to its Skill page or its source. A layout that drops provenance to save space fails review. Trust signals (official repository, owner-verified) are earned facts, never editorial favors.

Expand Down
75 changes: 75 additions & 0 deletions docs/adr/0015-follow-moved-repositories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# ADR-0015: Follow moved Repositories

**Status:** Accepted
**Date:** 2026-10-07

Replaces [ADR-0002](0002-freeze-registry-repository-identity.md).

## Context

ADR-0002 froze the registry identity at admission. A renamed or transferred
Repository kept its old `owner/repo` in every table and URL, and
`repos.source_owner` and `repos.source_repo` carried the new name for [GitHub](https://github.com)
reads.

That left pages and run commands on names GitHub no longer serves. On
2026-10-07 the run sweep found 229 Skills in 13 Repositories failing on GitHub's
301, `facebook/react` among them. Production held 503 Repositories under a name
that GitHub had moved. In two cases the new name was already a separate
registry Repository with the same Skills: `hyf0/vue-skills` and
`vuejs-ai/skills`, and `shadcn/ui` and `shadcn-ui/ui`. Those were duplicate
indexable pages.

## Decision

The registry follows GitHub. When sync reads a Repository and GitHub answers
with another name, every row moves to that name in one D1 batch.

- GitHub's numeric Repository ID survives renames and transfers. Sync stores it
in `repos.repository_id` and records it on the alias.
- The old name stays as a `repo_aliases` row. `/gh/<old-owner>/<old-repo>`{lang="html"} and
every path under it answer 301 to the same path under the new name, in one
hop. A later move rewrites older aliases to the newest name. A move back
deletes the alias.
- When the registry already holds the new name, the rows merge into it. A clash
keeps the row under the new name.
- A root Skill takes the Repository name, so a rename renames it. The alias
keeps both names, so its old page redirects too.
- `skilld run <old-owner>/<old-repo>/<skill>`{lang="html"} keeps working. The registry
answers the old name with the new identity, and delivery follows GitHub's
301 to the Repository ID.
- Discovery evidence, skills.sh observations, and sync checkpoints keep the
name they recorded.

`layers/registry/server/utils/repository-move.ts` holds the move.
`scripts/move-repositories.ts` runs the same statements as a one-off backfill.

## Consequences

Pages, sitemaps, and run commands show the name GitHub serves. The 301 carries
the old URL's links to the new one. The move adds no indexable page: each Skill
keeps its admission, and a merge removes a duplicate. VISION principle 2 holds.

Every moved Skill is queued in `skill_dirty`, so trust and indexability are
recomputed for the new identity. The `embedding` marker stays under the old
name. The vector ID hashes the name, so the nightly prune deletes the old vector
and the next generation embeds the new name.

Static lists keyed by name, such as `official-repos.ts` and the track pins in
`clusters.ts`, must name the new Repository. An official Repository lists both
names until no row carries the old one.

Moves come from GitHub, not from a person. A Repository whose old name GitHub
gives to another Repository becomes live again once the registry admits it, and
its alias stops redirecting.

Cull path: there is none while old URLs receive traffic. Dropping
`repo_aliases` ends every redirect.

## Rejected alternatives

Keeping ADR-0002 meant run commands and pages named Repositories GitHub no
longer serves, and duplicate Repositories stayed as duplicate pages.

A redirect without a move would answer every page view with a lookup and keep
two names for one Repository in every table.
2 changes: 2 additions & 0 deletions docs/arch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,6 @@ See [ADR-0007](../adr/0007-contract-in-the-cli-repository.md).
| Framework page | `/frameworks/[name]` |
| Comparison | `/compare/<slug>` |

A Repository that GitHub renamed or transferred moves to its new name, and every `/gh` URL under the old name answers 301 to the same path under the new one ([ADR-0015](../adr/0015-follow-moved-repositories.md)).

The legacy `/people/[handle]` and `/people/[handle]/collections/[slug]` URLs are 410 Gone (with a 301 special-case for `/people/harlanzw.com β†’ /@harlanzw`).
8 changes: 4 additions & 4 deletions layers/registry/server/data/clusters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export const CLUSTERS: Cluster[] = [
'ibelick/ui-skills/fixing-accessibility',
'ibelick/ui-skills/baseline-ui',
'nutlope/hallmark/hallmark',
'shadcn/ui/shadcn',
'shadcn-ui/ui/shadcn',
],
},
{
Expand Down Expand Up @@ -200,7 +200,7 @@ export const CLUSTERS: Cluster[] = [
pinnedExamples: [
'dmmulroy/anti-slop/install-anti-slop',
'cursor/plugins/thermo-nuclear-code-quality-review',
'brianlovin/claude-config/deslop',
'brianlovin/agent-config/deslop',
'cursor/plugins/deslop',
'davila7/claude-code-templates/deslop',
// Registry identity follows `docs/SKILL.md`; its frontmatter name
Expand Down Expand Up @@ -264,7 +264,7 @@ export const CLUSTERS: Cluster[] = [
'pbakaus/agent-reviews/resolve-agent-reviews',
'obra/superpowers/requesting-code-review',
'obra/superpowers/receiving-code-review',
'brianlovin/claude-config/fix-sentry-issues',
'brianlovin/agent-config/fix-sentry-issues',
'dimillian/skills/review-and-simplify-changes',
'github/awesome-copilot/refactor',
],
Expand Down Expand Up @@ -594,7 +594,7 @@ export const FRAMEWORK_PINNED_SKILLS: string[] = [
// /frameworks/react and /frameworks/nextjs
'vercel-labs/agent-skills/react-best-practices',
'vercel-labs/agent-skills/composition-patterns',
'vercel-labs/vercel-plugin/nextjs',
'vercel/vercel-plugin/nextjs',
'deckardger/tanstack-agent-skills/tanstack-query',
'vercel/next.js/next-cache-components-optimizer',
]
Expand Down
19 changes: 19 additions & 0 deletions layers/registry/server/data/official-repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export interface OfficialRepo {
* Org entries seeded from skills.sh/official on 2026-03-30. User entries
* seeded from registry on 2026-04-25, then maintained as a reviewed list.
* Maintained manually.
*
* A Repository GitHub moved keeps its old name here beside the new one, so
* its Skills stay official on both sides of the registry move (ADR-0015).
* Delete an old name once no registry row carries it.
*/
export const officialRepos: OfficialRepo[] = [
{ owner: 'anthropics', repo: 'skills', kind: 'org' },
Expand All @@ -29,7 +33,9 @@ export const officialRepos: OfficialRepo[] = [
{ owner: 'base', repo: 'skills', kind: 'org' },
{ owner: 'better-auth', repo: 'skills', kind: 'org' },
{ owner: 'bitwarden', repo: 'ai-plugins', kind: 'org' },
// Old name. ADR-0015 moves its rows to the entry below.
{ owner: 'box', repo: 'box-for-ai', kind: 'org' },
{ owner: 'box', repo: 'skills', kind: 'org' },
{ owner: 'brave', repo: 'brave-search-skills', kind: 'org' },
{ owner: 'browser-use', repo: 'browser-use', kind: 'org' },
{ owner: 'browserbase', repo: 'skills', kind: 'org' },
Expand All @@ -46,11 +52,15 @@ export const officialRepos: OfficialRepo[] = [
{ owner: 'elevenlabs', repo: 'skills', kind: 'org' },
{ owner: 'encoredev', repo: 'skills', kind: 'org' },
{ owner: 'expo', repo: 'skills', kind: 'org' },
// Old name. ADR-0015 moves its rows to the entry below.
{ owner: 'facebook', repo: 'react', kind: 'org' },
{ owner: 'react', repo: 'react', kind: 'org' },
{ owner: 'figma', repo: 'mcp-server-guide', kind: 'org' },
{ owner: 'firebase', repo: 'agent-skills', kind: 'org' },
{ owner: 'firecrawl', repo: 'cli', kind: 'org' },
// Old name. ADR-0015 moves its rows to the entry below.
{ owner: 'flutter', repo: 'skills', kind: 'org' },
{ owner: 'flutter', repo: 'agent-plugins', kind: 'org' },
{ owner: 'getsentry', repo: 'skills', kind: 'org' },
{ owner: 'github', repo: 'awesome-copilot', kind: 'org' },
{ owner: 'google-gemini', repo: 'gemini-skills', kind: 'org' },
Expand All @@ -61,7 +71,9 @@ export const officialRepos: OfficialRepo[] = [
{ owner: 'kotlin', repo: 'kotlin-agent-skills', kind: 'org' },
{ owner: 'langchain-ai', repo: 'langchain-skills', kind: 'org' },
{ owner: 'langfuse', repo: 'skills', kind: 'org' },
// Old name. ADR-0015 moves its rows to the entry below.
{ owner: 'launchdarkly', repo: 'agent-skills', kind: 'org' },
{ owner: 'launchdarkly', repo: 'ai-tooling', kind: 'org' },
{ owner: 'livekit', repo: 'agent-skills', kind: 'org' },
{ owner: 'makenotion', repo: 'claude-code-notion-plugin', kind: 'org' },
{ owner: 'mapbox', repo: 'mapbox-agent-skills', kind: 'org' },
Expand Down Expand Up @@ -90,7 +102,9 @@ export const officialRepos: OfficialRepo[] = [
{ owner: 'streamlit', repo: 'agent-skills', kind: 'org' },
{ owner: 'stripe', repo: 'ai', kind: 'org' },
{ owner: 'supabase', repo: 'agent-skills', kind: 'org' },
// Old name. ADR-0015 moves its rows to the entry below.
{ owner: 'sveltejs', repo: 'mcp', kind: 'org' },
{ owner: 'sveltejs', repo: 'ai-tools', kind: 'org' },
{ owner: 'vuejs-ai', repo: 'skills', kind: 'org' },
{ owner: 'vueuse', repo: 'skills', kind: 'org' },
{ owner: 'tavily-ai', repo: 'skills', kind: 'org' },
Expand All @@ -101,7 +115,9 @@ export const officialRepos: OfficialRepo[] = [
{ owner: 'vercel', repo: 'ai', kind: 'org' },
{ owner: 'vercel', repo: 'next.js', kind: 'org' },
{ owner: 'vercel-labs', repo: 'agent-skills', kind: 'org' },
// Old name. ADR-0015 moves its rows to the entry below.
{ owner: 'vercel-labs', repo: 'vercel-plugin', kind: 'org' },
{ owner: 'vercel', repo: 'vercel-plugin', kind: 'org' },
{ owner: 'vercel-labs', repo: 'agent-browser', kind: 'org' },
{ owner: 'webflow', repo: 'webflow-skills', kind: 'org' },
{ owner: 'wix', repo: 'skills', kind: 'org' },
Expand All @@ -118,9 +134,12 @@ export const officialRepos: OfficialRepo[] = [
{ owner: 'addyosmani', repo: 'web-quality-skills', kind: 'user' },
{ owner: 'mattpocock', repo: 'skills', kind: 'user' },
{ owner: 'ibelick', repo: 'ui-skills', kind: 'user' },
// Old name. ADR-0015 moves its rows to the entry below.
{ owner: 'brianlovin', repo: 'claude-config', kind: 'user' },
{ owner: 'brianlovin', repo: 'agent-config', kind: 'user' },
{ owner: 'dimillian', repo: 'skills', kind: 'user' },
{ owner: 'onmax', repo: 'nuxt-skills', kind: 'user' },
// Old name. ADR-0015 merges its rows into vuejs-ai/skills above.
{ owner: 'hyf0', repo: 'vue-skills', kind: 'user' },
{ owner: 'emilkowalski', repo: 'skills', kind: 'user' },
{ owner: 'nutlope', repo: 'hallmark', kind: 'user' },
Expand Down
29 changes: 29 additions & 0 deletions layers/registry/server/middleware/gh-moved-repository-redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
indexRepositoryAliases,
loadRepositoryAliases,
repositoryPathSegments,
resolveMovedRepositoryRoute,
} from '../utils/repository-aliases'

/**
* Answer every URL under a moved Repository with a 301 to its new name, so
* the old URLs pass their link equity on (ADR-0015). Paths without an owner
* and a Repository segment skip the cache read.
*/
export default defineEventHandler(async (event) => {
const url = getRequestURL(event)
if (!repositoryPathSegments(url.pathname))
return

const db = event.context.platform?.db
if (!db)
return

const aliases = await loadRepositoryAliases(useStorage('cache'), db)
if (!aliases?.length)
return

const decision = resolveMovedRepositoryRoute(url.pathname, url.search, indexRepositoryAliases(aliases))
if (decision._tag === 'redirect')
return sendRedirect(event, decision.location, 301)
})
5 changes: 2 additions & 3 deletions layers/registry/server/presenters/repository-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@ function byLastChange(left: ProfileSkill, right: ProfileSkill): number {
/**
* One Repository profile.
*
* `source` is where the Repository lives on GitHub now. The registry keeps the
* identity it first indexed (ADR-0002), and the two differ after a rename or
* a transfer.
* `source` is where the Repository lives on GitHub now. After a rename or a
* transfer the two differ until sync moves the registry rows (ADR-0015).
*/
export function presentRepository(
profile: LegacyOwnerProfile,
Expand Down
14 changes: 13 additions & 1 deletion layers/registry/server/utils/embedding-parity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,10 @@ export async function runEmbeddingParityCheck(
* hashes owner/repo/name with no sha in it, so any skill still carrying an
* `embedding` marker while failing the eligibility predicate is exactly one
* strandable vector, addressable by id without reading the index at all.
*
* A marker with no Skill row at all is one too. A Repository move leaves the
* marker under the old name on purpose (`repository-move.ts`), because the
* vector ID still hashes that name.
*/
const PRUNE_CANDIDATE_SQL = `
SELECT s.owner, s.repo, s.name
Expand All @@ -345,7 +349,15 @@ const PRUNE_CANDIDATE_SQL = `
AND s.rendered_status = 'ok'
AND s.seo_indexable = 1
)
ORDER BY s.owner ASC, s.repo ASC, s.name ASC
UNION ALL
SELECT marker.owner, marker.repo, marker.name
FROM skill_generated marker
WHERE marker.kind = 'embedding'
AND NOT EXISTS (
SELECT 1 FROM skills s
WHERE s.owner = marker.owner AND s.repo = marker.repo AND s.name = marker.name
)
ORDER BY owner ASC, repo ASC, name ASC
`

/**
Expand Down
27 changes: 23 additions & 4 deletions layers/registry/server/utils/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export async function getRepo(
}

interface RepoSummaryGql {
databaseId: number | null
name: string
nameWithOwner: string
url: string
Expand All @@ -251,16 +252,28 @@ interface RepoSummaryGql {
} | null
}

const REPO_SUMMARY_FIELDS = `name nameWithOwner url owner{login}
const REPO_SUMMARY_FIELDS = `databaseId name nameWithOwner url owner{login}
description stargazerCount forkCount pushedAt createdAt isArchived isFork
defaultBranchRef{name target{... on Commit{oid tree{oid}}}}`

export interface RepoSummary {
meta: RepoMeta
/**
* GitHub's numeric Repository ID. It survives every rename and transfer, so
* it confirms that an old name and a new name are one Repository.
*/
repositoryId: number
headTreeSha: string | null
}

function repoSummaryFromGql(r: RepoSummaryGql): RepoSummary {
/**
* Parse one GraphQL Repository into a summary, or null without a Repository
* ID. The schema allows a null ID, and a Repository without one cannot be
* followed across a move.
*/
function repoSummaryFromGql(r: RepoSummaryGql): RepoSummary | null {
if (typeof r.databaseId !== 'number')
return null
const branch = r.defaultBranchRef?.name || 'main'
const meta: RepoMeta = {
name: r.name,
Expand All @@ -276,7 +289,7 @@ function repoSummaryFromGql(r: RepoSummaryGql): RepoSummary {
archived: r.isArchived,
fork: r.isFork,
}
return { meta, headTreeSha: r.defaultBranchRef?.target?.tree.oid ?? null }
return { meta, repositoryId: r.databaseId, headTreeSha: r.defaultBranchRef?.target?.tree.oid ?? null }
}

/**
Expand Down Expand Up @@ -311,7 +324,11 @@ export async function getRepoSummary(
const r = out.data?.repository
if (!r)
return { status: 404, data: null, rateLimit: out.rateLimit, notModified: false }
return { status: 200, data: repoSummaryFromGql(r), rateLimit: out.rateLimit, notModified: false }
// A Repository without an ID fails like any other malformed answer.
const summary = repoSummaryFromGql(r)
return summary
? { status: 200, data: summary, rateLimit: out.rateLimit, notModified: false }
: { status: 502, data: null, rateLimit: out.rateLimit, notModified: false }
}

/**
Expand Down Expand Up @@ -377,6 +394,8 @@ export async function getRepoSummariesBatch(
return { _tag: 'failed', status: 502, rateLimit, requests: sent }
for (let i = 0; i < batch.length; i++) {
const r = out.data?.[`r${i}`]
// A summary without a usable Repository ID cannot be followed across a
// move, so it reads as no summary and the per-repository sync owns it.
summaries.push(r ? repoSummaryFromGql(r) : null)
}
}
Expand Down
Loading
Loading