From 5ac1b3c9cb1276c990680bd195b277aefac1cb4d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 04:39:59 +1100 Subject: [PATCH 1/5] fix(tracks): pin one Skill per owner/repo/name key An owner/name pin matched every repository of one owner that shipped a Skill of that name. /skills/design listed emil-design-eng and web-design-guidelines twice under its hand-picked heading, and the indexability pin signal reached the namesakes too. Pins now name the repository, resolved against production D1. The track board also lists one copy of an owner's Skill, so a renamed repository's old identity stays off the page. --- .../server/api/clusters/[slug].get.ts | 9 +- .../registry/server/api/clusters/index.get.ts | 7 +- layers/registry/server/data/clusters.ts | 272 +++++++++--------- .../server/utils/cluster-membership.ts | 12 +- .../server/utils/cluster-skill-curation.ts | 18 +- .../registry/server/utils/recompute-scores.ts | 2 +- layers/registry/server/utils/sync-repo.ts | 2 +- migrations/0139_skills_pin_key_index.sql | 14 + scripts/recompute-skill-indexability.ts | 2 +- shared/track-board.ts | 30 +- test/unit/category-pin-indexability.test.ts | 20 +- test/unit/cluster-membership.test.ts | 8 +- test/unit/cluster-page-sql.test.ts | 53 +++- test/unit/cluster-skill-curation.test.ts | 39 ++- test/unit/clusters-taxonomy.test.ts | 15 +- test/unit/d1-read-query-plan.test.ts | 6 +- test/unit/track-board.test.ts | 41 +++ test/unit/track-talked.test.ts | 2 +- 18 files changed, 341 insertions(+), 211 deletions(-) create mode 100644 migrations/0139_skills_pin_key_index.sql diff --git a/layers/registry/server/api/clusters/[slug].get.ts b/layers/registry/server/api/clusters/[slug].get.ts index e8e79066..9e8e148d 100644 --- a/layers/registry/server/api/clusters/[slug].get.ts +++ b/layers/registry/server/api/clusters/[slug].get.ts @@ -3,7 +3,7 @@ import { getDB } from '#server/utils/db' import { canonicalRepoSkillPath } from '#shared/skill-routes' import { CLUSTER_BY_SLUG } from '../../data/clusters' import { clusterPageSql } from '../../utils/cluster-membership' -import { curateClusterSkills, parseClusterSkillKeys } from '../../utils/cluster-skill-curation' +import { clusterSkillKey, curateClusterSkills, parseClusterSkillKeys } from '../../utils/cluster-skill-curation' function clusterMeta(cluster: Cluster) { return { @@ -95,7 +95,7 @@ export default defineCachedEventHandler(async (event) => { /** Skills in the repository. The board prints a run command only at 1. */ repoSkillCount: s.repo_skill_count, /** A person pinned it to this track, so the board lists it under the hand-picked heading. */ - pinned: pinnedKeys.has(`${s.owner}/${s.name}`), + pinned: pinnedKeys.has(clusterSkillKey(s)), })) const items = page === 1 ? curateClusterSkills(rankedItems, cluster.pinnedExamples) @@ -115,6 +115,7 @@ export default defineCachedEventHandler(async (event) => { maxAge: 60 * 10, staleMaxAge: 60 * 60, swr: true, - // v5: items carry `repoSkillCount` and `pinned` for the track board. - name: 'clusters-detail-origin-v5', + // v6: a pin matches `owner/repo/name`, so a namesake in another repository + // is no longer listed as pinned. + name: 'clusters-detail-origin-v6', }) diff --git a/layers/registry/server/api/clusters/index.get.ts b/layers/registry/server/api/clusters/index.get.ts index 110e6855..dc1f248f 100644 --- a/layers/registry/server/api/clusters/index.get.ts +++ b/layers/registry/server/api/clusters/index.get.ts @@ -11,7 +11,7 @@ import { getDB } from '#server/utils/db' import { CLUSTERS } from '../../data/clusters' import { findClusterIndexRows } from '../../utils/cluster-index-rows' import { clusterMembersSql } from '../../utils/cluster-membership' -import { curateClusterSkills, parseClusterSkillKeys } from '../../utils/cluster-skill-curation' +import { clusterSkillKey, curateClusterSkills, parseClusterSkillKeys } from '../../utils/cluster-skill-curation' interface SkillRow { owner: string @@ -70,7 +70,7 @@ export default defineCachedEventHandler(async (event) => { const items: ClusterCard[] = CLUSTERS.map((c: Cluster) => { const pinned = new Set(c.pinnedExamples) const inCluster = rows - .filter(r => (r.category !== null && c.categories.includes(r.category)) || pinned.has(`${r.owner}/${r.name}`)) + .filter(r => (r.category !== null && c.categories.includes(r.category)) || pinned.has(clusterSkillKey(r))) // Abstract skills lead the card: they are the classified curation signal, // and the backfill exists so the track has depth under them. .sort((a, b) => b.is_abstract - a.is_abstract || b.stars - a.stars || a.name.localeCompare(b.name)) @@ -120,5 +120,6 @@ export default defineCachedEventHandler(async (event) => { maxAge: 60 * 10, staleMaxAge: 60 * 60, swr: true, - name: 'clusters-index-origin-v5', + // v6: a pin matches `owner/repo/name`. + name: 'clusters-index-origin-v6', }) diff --git a/layers/registry/server/data/clusters.ts b/layers/registry/server/data/clusters.ts index 21265d43..34de6b6c 100644 --- a/layers/registry/server/data/clusters.ts +++ b/layers/registry/server/data/clusters.ts @@ -111,7 +111,11 @@ export interface Cluster { audience: 'dev' | 'test' /** Must be values ABSTRACTNESS_CATEGORIES actually emits. No repeats. */ categories: string[] - /** Hand-picked `owner/name` keys that lead cards and detail pages. */ + /** + * Hand-picked `owner/repo/name` keys that lead cards and detail pages. The + * repository is part of the key because one owner can ship a Skill of the + * same name in two repositories, and an `owner/name` pin matched both. + */ pinnedExamples: string[] /** * ISO date the track was admitted, or null for the rows that predate the @@ -141,15 +145,15 @@ export const CLUSTERS: Cluster[] = [ categories: ['interface-design'], addedAt: null, pinnedExamples: [ - 'emilkowalski/emil-design-eng', - 'jakubkrehel/make-interfaces-feel-better', - 'pbakaus/impeccable', - 'vercel-labs/web-design-guidelines', - 'ibelick/fixing-motion-performance', - 'ibelick/fixing-accessibility', - 'ibelick/baseline-ui', - 'nutlope/hallmark', - 'shadcn/shadcn', + 'emilkowalski/skills/emil-design-eng', + 'jakubkrehel/make-interfaces-feel-better/make-interfaces-feel-better', + 'pbakaus/impeccable/impeccable', + 'vercel-labs/agent-skills/web-design-guidelines', + 'ibelick/ui-skills/fixing-motion-performance', + 'ibelick/ui-skills/fixing-accessibility', + 'ibelick/ui-skills/baseline-ui', + 'nutlope/hallmark/hallmark', + 'shadcn/ui/shadcn', ], }, { @@ -168,14 +172,14 @@ export const CLUSTERS: Cluster[] = [ categories: ['framework', 'rendering'], addedAt: null, pinnedExamples: [ - 'vercel-labs/find-skills', - 'othmanadi/planning-with-files', - 'mattpocock/grill-me', - 'obra/systematic-debugging', - 'mattpocock/tdd', - 'mattpocock/improve-codebase-architecture', - 'mattpocock/domain-modeling', - 'anthropics/frontend-design', + 'vercel-labs/skills/find-skills', + 'othmanadi/planning-with-files/planning-with-files', + 'mattpocock/skills/grill-me', + 'obra/superpowers/systematic-debugging', + 'mattpocock/skills/tdd', + 'mattpocock/skills/improve-codebase-architecture', + 'mattpocock/skills/domain-modeling', + 'anthropics/skills/frontend-design', ], }, { @@ -194,15 +198,15 @@ export const CLUSTERS: Cluster[] = [ categories: [], addedAt: null, pinnedExamples: [ - 'dmmulroy/install-anti-slop', - 'cursor/thermo-nuclear-code-quality-review', - 'brianlovin/deslop', - 'cursor/deslop', - 'davila7/deslop', + 'dmmulroy/anti-slop/install-anti-slop', + 'cursor/plugins/thermo-nuclear-code-quality-review', + 'brianlovin/claude-config/deslop', + 'cursor/plugins/deslop', + 'davila7/claude-code-templates/deslop', // Registry identity follows `docs/SKILL.md`; its frontmatter name // (`desloppify`) renders as the display name. - 'peteromallet/docs', - 'asyrafhussin/code-slop', + 'peteromallet/desloppify/docs', + 'asyrafhussin/agent-skills/code-slop', ], }, { @@ -222,23 +226,23 @@ export const CLUSTERS: Cluster[] = [ categories: ['automation'], addedAt: null, pinnedExamples: [ - 'obra/subagent-driven-development', - 'obra/dispatching-parallel-agents', - 'obra/verification-before-completion', - 'mattpocock/handoff', - 'obra/executing-plans', - 'anthropics/skill-creator', - 'obra/writing-skills', - 'openai/skill-installer', - 'callstackincubator/validate-skills', - 'anthropics/mcp-builder', + 'obra/superpowers/subagent-driven-development', + 'obra/superpowers/dispatching-parallel-agents', + 'obra/superpowers/verification-before-completion', + 'mattpocock/skills/handoff', + 'obra/superpowers/executing-plans', + 'anthropics/skills/skill-creator', + 'obra/superpowers/writing-skills', + 'openai/skills/skill-installer', + 'callstackincubator/agent-skills/validate-skills', + 'anthropics/skills/mcp-builder', // Browser control retired into this category on 2026-08-13: driving a // browser is what an agent does inside a workflow, not a track someone // picks. `/skills/browser-automation` 301s here. - 'microsoft/playwright-cli', - 'vercel-labs/agent-browser', - 'browser-use/browser-use', - 'firecrawl/firecrawl-cli', + 'microsoft/playwright-cli/playwright-cli', + 'vercel-labs/agent-browser/agent-browser', + 'browser-use/browser-use/browser-use', + 'firecrawl/cli/firecrawl-cli', ], }, { @@ -257,12 +261,12 @@ export const CLUSTERS: Cluster[] = [ categories: ['code-review', 'refactoring'], addedAt: null, pinnedExamples: [ - 'pbakaus/resolve-agent-reviews', - 'obra/requesting-code-review', - 'obra/receiving-code-review', - 'brianlovin/fix-sentry-issues', - 'dimillian/review-and-simplify-changes', - 'github/refactor', + 'pbakaus/agent-reviews/resolve-agent-reviews', + 'obra/superpowers/requesting-code-review', + 'obra/superpowers/receiving-code-review', + 'brianlovin/claude-config/fix-sentry-issues', + 'dimillian/skills/review-and-simplify-changes', + 'github/awesome-copilot/refactor', ], }, { @@ -283,14 +287,14 @@ export const CLUSTERS: Cluster[] = [ categories: ['testing', 'incident-response', 'observability'], addedAt: null, pinnedExamples: [ - 'mattpocock/tdd', - 'obra/test-driven-development', - 'obra/systematic-debugging', - 'bitwarden/assessing-test-coverage', - 'wdm0006/verifying-external-behavior', - 'anthropics/webapp-testing', - 'deanpeters/autonomous-investigation', - 'boshu2/post-mortem', + 'mattpocock/skills/tdd', + 'obra/superpowers/test-driven-development', + 'obra/superpowers/systematic-debugging', + 'bitwarden/ai-plugins/assessing-test-coverage', + 'wdm0006/python-skills/verifying-external-behavior', + 'anthropics/skills/webapp-testing', + 'deanpeters/product-manager-skills/autonomous-investigation', + 'boshu2/agentops/post-mortem', ], }, { @@ -309,13 +313,13 @@ export const CLUSTERS: Cluster[] = [ categories: ['performance'], addedAt: null, pinnedExamples: [ - 'addyosmani/web-quality-audit', - 'addyosmani/core-web-vitals', - 'addyosmani/performance', - 'addyosmani/accessibility', - 'addyosmani/seo', - 'addyosmani/best-practices', - 'millionco/react-doctor', + 'addyosmani/web-quality-skills/web-quality-audit', + 'addyosmani/web-quality-skills/core-web-vitals', + 'addyosmani/web-quality-skills/performance', + 'addyosmani/web-quality-skills/accessibility', + 'addyosmani/web-quality-skills/seo', + 'addyosmani/web-quality-skills/best-practices', + 'millionco/react-doctor/react-doctor', ], }, { @@ -334,16 +338,16 @@ export const CLUSTERS: Cluster[] = [ categories: ['data-modeling', 'security', 'auth'], addedAt: null, pinnedExamples: [ - 'supabase/supabase-postgres-best-practices', - 'stripe/stripe-best-practices', - 'get-convex/convex-quickstart', - 'firebase/firebase-basics', - 'resend/resend', - 'wshobson/stride-analysis-patterns', - 'github/audit-integrity', - 'bitwarden/bitwarden-security-context', - 'wshobson/auth-implementation-patterns', - 'better-auth/best-practices', + 'supabase/agent-skills/supabase-postgres-best-practices', + 'stripe/ai/stripe-best-practices', + 'get-convex/agent-skills/convex-quickstart', + 'firebase/agent-skills/firebase-basics', + 'resend/resend-skills/resend', + 'wshobson/agents/stride-analysis-patterns', + 'github/awesome-copilot/audit-integrity', + 'bitwarden/ai-plugins/bitwarden-security-context', + 'wshobson/agents/auth-implementation-patterns', + 'better-auth/skills/best-practices', ], }, { @@ -361,11 +365,11 @@ export const CLUSTERS: Cluster[] = [ categories: ['ci-cd', 'deployment', 'release-management', 'migrations'], addedAt: null, pinnedExamples: [ - 'obra/using-git-worktrees', - 'github/git-commit', - 'jimliu/release-skills', - 'obra/finishing-a-development-branch', - 'github/conventional-commit', + 'obra/superpowers/using-git-worktrees', + 'github/awesome-copilot/git-commit', + 'jimliu/baoyu-skills/release-skills', + 'obra/superpowers/finishing-a-development-branch', + 'github/awesome-copilot/conventional-commit', ], }, { @@ -384,11 +388,11 @@ export const CLUSTERS: Cluster[] = [ categories: ['planning', 'project-management', 'software-design'], addedAt: null, pinnedExamples: [ - 'obra/brainstorming', - 'n8n-io/spec-driven-development', - 'vercel/adr-skill', - 'obra/writing-plans', - 'obra/executing-plans', + 'obra/superpowers/brainstorming', + 'n8n-io/n8n/spec-driven-development', + 'vercel/ai/adr-skill', + 'obra/superpowers/writing-plans', + 'obra/superpowers/executing-plans', ], }, @@ -410,18 +414,18 @@ export const CLUSTERS: Cluster[] = [ categories: ['documentation'], addedAt: null, pinnedExamples: [ - 'hardikpandya/stop-slop', - 'petergyang/no-ai-slop', - 'blader/humanizer', - 'cursor/unslop', - 'ehmo/slopbeth', - 'Aboudjem/humanizer', - 'stephenturner/deslop', - 'elithrar/anti-slop', + 'hardikpandya/stop-slop/stop-slop', + 'petergyang/no-ai-slop/no-ai-slop', + 'blader/humanizer/humanizer', + 'cursor/plugins/unslop', + 'ehmo/slopkit/slopbeth', + 'Aboudjem/humanizer-skill/humanizer', + 'stephenturner/skill-deslop/deslop', + 'elithrar/dotfiles/anti-slop', // Registry name is the repo slug; the skill's frontmatter name // (`humanize`) renders as its display name. - 'aashaexo/soundshuman', - 'jalaalrd/anti-ai-slop-writing', + 'aashaexo/soundshuman/soundshuman', + 'jalaalrd/anti-ai-slop-writing/anti-ai-slop-writing', ], }, @@ -445,15 +449,15 @@ export const CLUSTERS: Cluster[] = [ categories: ['diagramming'], addedAt: '2026-09-04', pinnedExamples: [ - 'tt-a1i/archify', - 'garrytan/diagram', - 'kingbootoshi/cartographer', - 'coldteadotai/pr-lens', - 'github/architecture-blueprint-generator', - 'github/excalidraw-diagram-generator', - 'cathrynlavery/diagram-design', - 'humanlayer/show-me', - 'kepano/json-canvas', + 'tt-a1i/archify/archify', + 'garrytan/gstack/diagram', + 'kingbootoshi/cartographer/cartographer', + 'coldteadotai/pr-lens/pr-lens', + 'github/awesome-copilot/architecture-blueprint-generator', + 'github/awesome-copilot/excalidraw-diagram-generator', + 'cathrynlavery/diagram-design/diagram-design', + 'humanlayer/skills/show-me', + 'kepano/obsidian-skills/json-canvas', ], }, { @@ -472,14 +476,14 @@ export const CLUSTERS: Cluster[] = [ categories: ['scraping'], addedAt: '2026-09-04', pinnedExamples: [ - 'browser-use/browser-use', - 'mattpocock/research', - 'mvanhorn/last30days', - 'kepano/defuddle', - 'firecrawl/convert-documents-to-markdown', - 'garrytan/scrape', - 'bytedance/github-deep-research', - 'imbad0202/deep-research', + 'browser-use/browser-use/browser-use', + 'mattpocock/skills/research', + 'mvanhorn/last30days-skill/last30days', + 'kepano/obsidian-skills/defuddle', + 'firecrawl/anydoc/convert-documents-to-markdown', + 'garrytan/gstack/scrape', + 'bytedance/deer-flow/github-deep-research', + 'imbad0202/academic-research-skills/deep-research', ], }, @@ -505,16 +509,16 @@ export const CLUSTERS: Cluster[] = [ pinnedExamples: [ // Addy Osmani's, already the anchor of /skills/performance. Pinned in // both because it genuinely answers both questions. - 'addyosmani/seo', - 'onmax/nuxt-seo', - 'agricidaniel/seo-technical', - 'agricidaniel/seo-schema', - 'agricidaniel/seo-programmatic', - 'agricidaniel/seo-sitemap', - 'coreyhaines31/seo-audit', - 'coreyhaines31/programmatic-seo', - 'coreyhaines31/ai-seo', - 'coreyhaines31/schema-markup', + 'addyosmani/web-quality-skills/seo', + 'onmax/nuxt-skills/nuxt-seo', + 'agricidaniel/claude-seo/seo-technical', + 'agricidaniel/claude-seo/seo-schema', + 'agricidaniel/claude-seo/seo-programmatic', + 'agricidaniel/claude-seo/seo-sitemap', + 'coreyhaines31/marketingskills/seo-audit', + 'coreyhaines31/marketingskills/programmatic-seo', + 'coreyhaines31/marketingskills/ai-seo', + 'coreyhaines31/marketingskills/schema-markup', ], // `aaron-he-zhu/seo-geo-claude-skills` looked like the strongest technical // match and even has its own search demand, but both of its skills return @@ -559,7 +563,7 @@ export const MERGED_COLLECTIONS: Record = Object.fromEntries( * Keep the redirects in nuxt.config.ts in step with this map. */ /** - * Every pinned `owner/name` key across all categories. + * Every pinned `owner/repo/name` key across all categories. * * One definition, because three things read it: the cluster queries, the * indexability recompute (a pin is a primary trust signal), and the skills @@ -578,21 +582,21 @@ export const MERGED_COLLECTIONS: Record = Object.fromEntries( */ export const FRAMEWORK_PINNED_SKILLS: string[] = [ // /frameworks/vue and /frameworks/nuxt - 'vuejs-ai/vue-best-practices', - 'vuejs-ai/vue-testing-best-practices', - 'onmax/nuxt', - 'antfu/vue', - 'antfu/pinia', - 'nuxt/nuxt-ui', - 'vueuse/vueuse-functions', - 'harlan-zw/nuxt-frontend-design', - 'harlan-zw/nuxt-frontend-review', + 'vuejs-ai/skills/vue-best-practices', + 'vuejs-ai/skills/vue-testing-best-practices', + 'onmax/nuxt-skills/nuxt', + 'antfu/skills/vue', + 'antfu/skills/pinia', + 'nuxt/ui/nuxt-ui', + 'vueuse/skills/vueuse-functions', + 'harlan-zw/harlan-agent-kit/nuxt-frontend-design', + 'harlan-zw/harlan-agent-kit/nuxt-frontend-review', // /frameworks/react and /frameworks/nextjs - 'vercel-labs/react-best-practices', - 'vercel-labs/composition-patterns', - 'vercel-labs/nextjs', - 'deckardger/tanstack-query', - 'vercel/next-cache-components-optimizer', + 'vercel-labs/agent-skills/react-best-practices', + 'vercel-labs/agent-skills/composition-patterns', + 'vercel-labs/vercel-plugin/nextjs', + 'deckardger/tanstack-agent-skills/tanstack-query', + 'vercel/next.js/next-cache-components-optimizer', ] export const PINNED_SKILL_KEYS: Set = new Set([ @@ -600,8 +604,8 @@ export const PINNED_SKILL_KEYS: Set = new Set([ ...FRAMEWORK_PINNED_SKILLS, ]) -export function isCategoryPinned(owner: string, name: string): boolean { - return PINNED_SKILL_KEYS.has(`${owner}/${name}`) +export function isCategoryPinned(owner: string, repo: string, name: string): boolean { + return PINNED_SKILL_KEYS.has(`${owner}/${repo}/${name}`) } export const RENAMED_CLUSTER_SLUGS: Record = { diff --git a/layers/registry/server/utils/cluster-membership.ts b/layers/registry/server/utils/cluster-membership.ts index 48cf9e82..3671933f 100644 --- a/layers/registry/server/utils/cluster-membership.ts +++ b/layers/registry/server/utils/cluster-membership.ts @@ -59,7 +59,7 @@ function membershipArms(categories: string[], pinnedKeys: string[]): { arms: str params.push(...categories) } if (pinnedKeys.length) { - arms.push(`(s.owner || '/' || s.name IN (${placeholders(pinnedKeys.length)}))`) + arms.push(`(s.owner || '/' || s.repo || '/' || s.name IN (${placeholders(pinnedKeys.length)}))`) params.push(...pinnedKeys) } return { arms, params } @@ -86,7 +86,7 @@ export function clusterMembersSql( // twice: D1 caps one statement at 100 bound parameters. const needsPinnedCase = Boolean(pinnedKeys.length && categories.length) const pinnedArm = needsPinnedCase - ? `CASE WHEN s.owner || '/' || s.name IN (${placeholders(pinnedKeys.length)}) THEN 1 ELSE 0 END` + ? `CASE WHEN s.owner || '/' || s.repo || '/' || s.name IN (${placeholders(pinnedKeys.length)}) THEN 1 ELSE 0 END` : (pinnedKeys.length ? '1' : '0') // D1 binds by position in the statement text, and the pinned CASE sits in the // SELECT list, ahead of the WHERE arms. @@ -144,8 +144,8 @@ export interface ClusterPageSql { * * They live here rather than in the route because the membership fragment is * not what D1 counts. A pinned key is bound once in the SELECT `CASE`, once in - * the WHERE arm, and twice more in the ORDER BY `CASE`, so a guard on the - * fragment reads a quarter of the real parameter count. + * the WHERE arm, and three more times in the ORDER BY `CASE`, as owner, repo + * and name. A guard on the fragment reads a fifth of the real parameter count. */ export function clusterPageSql( columns: string, @@ -159,7 +159,7 @@ export function clusterPageSql( // stars drops the pinned-order arm rather than emitting an empty CASE. const pinnedOrderSql = pinnedSkills.length ? `CASE - ${pinnedSkills.map((_, index) => `WHEN owner = ? AND name = ? THEN ${index}`).join('\n')} + ${pinnedSkills.map((_, index) => `WHEN owner = ? AND repo = ? AND name = ? THEN ${index}`).join('\n')} ELSE ${pinnedSkills.length} END,` : '' @@ -192,7 +192,7 @@ export function clusterPageSql( // The ORDER BY `CASE` sits in the window, which comes before the // membership fragment in the statement text, so its keys bind first. listParams: [ - ...pinnedSkills.flatMap(skill => [skill.owner, skill.name]), + ...pinnedSkills.flatMap(skill => [skill.owner, skill.repo, skill.name]), ...members.params, pageWindow.limit, pageWindow.offset, diff --git a/layers/registry/server/utils/cluster-skill-curation.ts b/layers/registry/server/utils/cluster-skill-curation.ts index 997c28bd..576610a0 100644 --- a/layers/registry/server/utils/cluster-skill-curation.ts +++ b/layers/registry/server/utils/cluster-skill-curation.ts @@ -1,5 +1,6 @@ interface ClusterSkillIdentity { owner: string + repo: string name: string } @@ -9,17 +10,22 @@ export interface ParsedClusterSkillKey extends ClusterSkillIdentity { const LEADING_SKILL_COUNT = 3 -function skillKey(skill: ClusterSkillIdentity): string { - return `${skill.owner}/${skill.name}` +/** + * The `owner/repo/name` key a pin is written in. A Skill name repeats across + * repositories, even under one owner, so a key without the repository can + * match two Skills. + */ +export function clusterSkillKey(skill: ClusterSkillIdentity): string { + return `${skill.owner}/${skill.repo}/${skill.name}` } export function parseClusterSkillKeys(keys: string[]): ParsedClusterSkillKey[] { return keys.map((key) => { const parts = key.split('/') - if (parts.length !== 2 || !parts[0] || !parts[1]) - throw new Error(`Invalid cluster skill key: ${key}`) + if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) + throw new Error(`Invalid cluster skill key, expected owner/repo/name: ${key}`) - return { key, owner: parts[0], name: parts[1] } + return { key, owner: parts[0], repo: parts[1], name: parts[2] } }) } @@ -29,7 +35,7 @@ export function curateClusterSkills( ): T[] { const pinRank = new Map(pinnedExamples.map((key, index) => [key, index])) const ranked = skills - .map((skill, index) => ({ skill, index, pinRank: pinRank.get(skillKey(skill)) ?? Number.MAX_SAFE_INTEGER })) + .map((skill, index) => ({ skill, index, pinRank: pinRank.get(clusterSkillKey(skill)) ?? Number.MAX_SAFE_INTEGER })) .sort((a, b) => a.pinRank - b.pinRank || a.index - b.index) .map(entry => entry.skill) diff --git a/layers/registry/server/utils/recompute-scores.ts b/layers/registry/server/utils/recompute-scores.ts index a0efc8c7..cd194b7b 100644 --- a/layers/registry/server/utils/recompute-scores.ts +++ b/layers/registry/server/utils/recompute-scores.ts @@ -165,7 +165,7 @@ function computeFromRow(row: ScoreRow, now: number): { trustTier: trust.tier, curatorCount: row.curator_count, curatorReasonCount: row.curator_reason_count, - categoryPinned: isCategoryPinned(row.owner, row.name), + categoryPinned: isCategoryPinned(row.owner, row.repo, row.name), approvedSocialCount: row.approved_social_count, authorSocialCount: row.author_social_count, stars: row.stars, diff --git a/layers/registry/server/utils/sync-repo.ts b/layers/registry/server/utils/sync-repo.ts index ace71544..f7844afa 100644 --- a/layers/registry/server/utils/sync-repo.ts +++ b/layers/registry/server/utils/sync-repo.ts @@ -1159,7 +1159,7 @@ export async function syncRepo( // Sync has no collection joins, so without this a pinned skill would be // written back as noindex on every repo sync and silently undo the // curation call until the next full recompute. - categoryPinned: isCategoryPinned(owner, parsed.name), + categoryPinned: isCategoryPinned(owner, repo, parsed.name), approvedSocialCount: 0, authorSocialCount: 0, stars, diff --git a/migrations/0139_skills_pin_key_index.sql b/migrations/0139_skills_pin_key_index.sql new file mode 100644 index 00000000..e6d30dcf --- /dev/null +++ b/migrations/0139_skills_pin_key_index.sql @@ -0,0 +1,14 @@ +-- Track pins name one Skill as `owner/repo/name`. An `owner/name` pin matched +-- every repository of one owner that shipped a Skill of that name, so +-- `/skills/design` listed two of its pins twice. +-- +-- The pinned arm of `clusterMembersSql` (`cluster-membership.ts`) now matches +-- `s.owner || '/' || s.repo || '/' || s.name IN (...)`. This index serves that +-- expression, as idx_skills_owner_name_key served the old one. The expression +-- matches the query without the table alias, as in migration 0123. +-- +-- No query reads the old expression after this change, so its index goes. +CREATE INDEX IF NOT EXISTS idx_skills_owner_repo_name_key + ON skills((owner || '/' || repo || '/' || name)); + +DROP INDEX IF EXISTS idx_skills_owner_name_key; diff --git a/scripts/recompute-skill-indexability.ts b/scripts/recompute-skill-indexability.ts index f274d1b3..6334d277 100644 --- a/scripts/recompute-skill-indexability.ts +++ b/scripts/recompute-skill-indexability.ts @@ -245,7 +245,7 @@ function main() { trustTier: trust.tier, curatorCount: row.curator_count, curatorReasonCount: row.curator_reason_count, - categoryPinned: isCategoryPinned(row.owner, row.name), + categoryPinned: isCategoryPinned(row.owner, row.repo, row.name), approvedSocialCount: row.approved_social_count, authorSocialCount: row.author_social_count, stars: row.stars, diff --git a/shared/track-board.ts b/shared/track-board.ts index 3269360d..426cefb8 100644 --- a/shared/track-board.ts +++ b/shared/track-board.ts @@ -185,25 +185,42 @@ function memberRow(member: TrackMemberInput): TrendingBoardRow { * page never prints two numbers against one Skill. Under the minimum the * talked Skills stay where the lists put them, and draw no posts: a row only * draws the signal that ranked it. + * + * One owner's Skill of one name is one Skill to a reader, even when two + * repositories ship it: a renamed repository keeps its old registry identity + * (ADR-0002), and a plugin bundle copies a Skill under the same name. The + * first list to claim it keeps it: the talked section, then the pins, then + * the most-starred copy. */ export function trackBoard(input: TrackBoardInput): TrackBoard { const { noun, clockSeconds } = input const period = trackRangeMeta(input.range).period const talked = input.talked.filter(skill => skill.posts.length > 0) + const leading = talked.length >= MIN_TALKED_SKILLS ? talked.slice(0, MAX_TALKED_ROWS) : [] const sections: TrackBoardSection[] = [] - if (talked.length >= MIN_TALKED_SKILLS) { + const shownPaths = new Set() + const shownNames = new Set() + const claim = (skill: { owner: string, name: string, registryPath: string }): boolean => { + const name = `${skill.owner}/${skill.name}` + if (shownPaths.has(skill.registryPath) || shownNames.has(name)) + return false + shownPaths.add(skill.registryPath) + shownNames.add(name) + return true + } + + if (leading.length) { + leading.forEach(claim) sections.push({ _tag: 'talked', id: 'track-talked-heading', heading: `${capitalise(noun)} skills devs talked about ${period}`, - rows: talked.slice(0, MAX_TALKED_ROWS).map(skill => talkedRow(skill, clockSeconds)), + rows: leading.map(skill => talkedRow(skill, clockSeconds)), }) } - const shown = new Set(sections.flatMap(section => section.rows.map(row => row.key))) - const rest = input.members.filter(member => !shown.has(member.registryPath)) - const picked = rest.filter(member => member.pinned) + const picked = input.members.filter(member => member.pinned).filter(claim) if (picked.length) { sections.push({ _tag: 'picked', @@ -213,9 +230,10 @@ export function trackBoard(input: TrackBoardInput): TrackBoard { }) } - const byStars = rest + const byStars = input.members .filter(member => !member.pinned) .sort((a, b) => b.stars - a.stars || a.name.localeCompare(b.name)) + .filter(claim) if (byStars.length) { sections.push({ _tag: 'stars', diff --git a/test/unit/category-pin-indexability.test.ts b/test/unit/category-pin-indexability.test.ts index 1f992891..8f0addff 100644 --- a/test/unit/category-pin-indexability.test.ts +++ b/test/unit/category-pin-indexability.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { CLUSTERS, isCategoryPinned, PINNED_SKILL_KEYS } from '../../layers/registry/server/data/clusters' +import { CLUSTERS, FRAMEWORK_PINNED_SKILLS, isCategoryPinned } from '../../layers/registry/server/data/clusters' +import { parseClusterSkillKeys } from '../../layers/registry/server/utils/cluster-skill-curation' import { scoreSkillIndexability, SEO_INDEXABLE_MIN_SCORE } from '../../layers/registry/server/utils/skill-indexability' /** @@ -82,13 +83,16 @@ describe('category pin indexability', () => { }) it('exposes every pin through one lookup the recompute and sitemap share', () => { - const fromClusters = CLUSTERS.flatMap(cluster => cluster.pinnedExamples) + const pins = parseClusterSkillKeys([...CLUSTERS.flatMap(cluster => cluster.pinnedExamples), ...FRAMEWORK_PINNED_SKILLS]) + for (const { key, owner, repo, name } of pins) + expect(isCategoryPinned(owner, repo, name), key).toBe(true) + expect(isCategoryPinned('nobody', 'skills', 'not-a-skill')).toBe(false) + }) - expect(PINNED_SKILL_KEYS.size).toBeGreaterThan(0) - for (const key of fromClusters) { - const slash = key.indexOf('/') - expect(isCategoryPinned(key.slice(0, slash), key.slice(slash + 1)), key).toBe(true) - } - expect(isCategoryPinned('nobody', 'not-a-skill')).toBe(false) + it('gives the pin to one Skill, not a namesake in another repository', () => { + // `emilkowalski/skill` is the registry identity of `emilkowalski/skills` + // before a rename. The pin names the canonical one. + expect(isCategoryPinned('emilkowalski', 'skills', 'emil-design-eng')).toBe(true) + expect(isCategoryPinned('emilkowalski', 'skill', 'emil-design-eng')).toBe(false) }) }) diff --git a/test/unit/cluster-membership.test.ts b/test/unit/cluster-membership.test.ts index 54db19c5..4c9453e0 100644 --- a/test/unit/cluster-membership.test.ts +++ b/test/unit/cluster-membership.test.ts @@ -9,17 +9,17 @@ import { clusterMembersSql } from '../../layers/registry/server/utils/cluster-me */ describe('cluster membership sql', () => { it('binds one param per placeholder, in statement order', () => { - const { sql, params } = clusterMembersSql('owner, name', ['testing', 'observability'], ['obra/tdd']) + const { sql, params } = clusterMembersSql('owner, name', ['testing', 'observability'], ['obra/skills/tdd']) expect(sql.match(/\?/g)).toHaveLength(params.length) // The pinned CASE sits in the SELECT list, ahead of both WHERE arms. - expect(params).toEqual(['obra/tdd', 'testing', 'observability', 'obra/tdd']) + expect(params).toEqual(['obra/skills/tdd', 'testing', 'observability', 'obra/skills/tdd']) }) it('skips the pinned CASE when nothing else can match', () => { - const { sql, params } = clusterMembersSql('owner, name', [], ['obra/tdd', 'mattpocock/tdd']) + const { sql, params } = clusterMembersSql('owner, name', [], ['obra/skills/tdd', 'mattpocock/skills/tdd']) - expect(params).toEqual(['obra/tdd', 'mattpocock/tdd']) + expect(params).toEqual(['obra/skills/tdd', 'mattpocock/skills/tdd']) expect(sql).not.toContain('CASE') expect(sql.match(/\?/g)).toHaveLength(2) }) diff --git a/test/unit/cluster-page-sql.test.ts b/test/unit/cluster-page-sql.test.ts index 476fdff6..2b4ce272 100644 --- a/test/unit/cluster-page-sql.test.ts +++ b/test/unit/cluster-page-sql.test.ts @@ -9,22 +9,23 @@ const COLUMNS = 'owner, name, repo, display_name, description, stars, modified_a /** * `/api/clusters/` sends two statements built from the membership * fragment, and the fragment is not what D1 counts. The list statement binds - * every pinned key three times: once for the SELECT `CASE`, once for the WHERE - * arm, and twice more for the ORDER BY `CASE`, plus limit and offset. Guarding - * only the fragment let SKILLD-X reach production at 173 events. + * every pinned key five times: once for the SELECT `CASE`, once for the WHERE + * arm, and as owner, repo and name in the ORDER BY `CASE`, plus limit and + * offset. Guarding only the fragment let SKILLD-X reach production at 173 + * events. */ describe('cluster page sql', () => { it('binds one param per placeholder in both statements', () => { - const page = clusterPageSql(COLUMNS, ['testing'], parseClusterSkillKeys(['obra/tdd']), { limit: 60, offset: 0 }) + const page = clusterPageSql(COLUMNS, ['testing'], parseClusterSkillKeys(['obra/skills/tdd']), { limit: 60, offset: 0 }) expect(page.countSql.match(/\?/g)).toHaveLength(page.countParams.length) expect(page.listSql.match(/\?/g)).toHaveLength(page.listParams.length) }) it('orders pinned skills ahead of the star ranking', () => { - const page = clusterPageSql(COLUMNS, [], parseClusterSkillKeys(['obra/tdd', 'shadcn/shadcn']), { limit: 10, offset: 20 }) + const page = clusterPageSql(COLUMNS, [], parseClusterSkillKeys(['obra/skills/tdd', 'shadcn/ui/shadcn']), { limit: 10, offset: 20 }) - expect(page.listParams.slice(0, 4)).toEqual(['obra', 'tdd', 'shadcn', 'shadcn']) + expect(page.listParams.slice(0, 6)).toEqual(['obra', 'skills', 'tdd', 'shadcn', 'ui', 'shadcn']) expect(page.listParams.slice(-2)).toEqual([10, 20]) }) @@ -54,7 +55,7 @@ describe('cluster page sql', () => { ('obra', 'skills', 'tdd', 'TDD', NULL, 1, 'other', 0, 0, 1), ('solo', 'one', 'c', 'C', NULL, 1, 'testing', 1, 0, 1); `) - const page = clusterPageSql(COLUMNS, ['testing'], parseClusterSkillKeys(['obra/tdd']), { limit: 3, offset: 0 }) + const page = clusterPageSql(COLUMNS, ['testing'], parseClusterSkillKeys(['obra/skills/tdd']), { limit: 3, offset: 0 }) const rows = sqlite.prepare(page.listSql).all(...page.listParams) as { owner: string, name: string, repo_skill_count: number }[] expect(rows.map(row => [`${row.owner}/${row.name}`, row.repo_skill_count])).toEqual([ @@ -69,6 +70,44 @@ describe('cluster page sql', () => { } }) + it('pins one design Skill when another repository ships one of the same name', () => { + // Production on 2026-10-06: `emilkowalski/skill` is the pre-rename identity + // of `emilkowalski/skills`, and `vercel-labs/openreview` ships its own + // `web-design-guidelines`. An `owner/name` pin matched both of each, so the + // hand-picked section listed two Skills twice. + const sqlite = new Database(':memory:') + try { + sqlite.exec(` + CREATE TABLE repos (owner TEXT, repo TEXT, stars INTEGER, PRIMARY KEY (owner, repo)); + CREATE TABLE skills ( + owner TEXT, repo TEXT, name TEXT, display_name TEXT, description TEXT, + modified_at INTEGER, abstractness_category TEXT, is_abstract INTEGER, + seo_indexable INTEGER, source_resolved INTEGER, + PRIMARY KEY (owner, repo, name) + ); + INSERT INTO repos VALUES + ('emilkowalski', 'skill', 43570), ('emilkowalski', 'skills', 43570), + ('vercel-labs', 'agent-skills', 31954), ('vercel-labs', 'openreview', 1697); + INSERT INTO skills VALUES + ('emilkowalski', 'skill', 'emil-design-eng', 'emil-design-eng', NULL, 1, 'other', 0, 0, 1), + ('emilkowalski', 'skills', 'emil-design-eng', 'emil-design-eng', NULL, 1, 'other', 0, 0, 1), + ('vercel-labs', 'agent-skills', 'web-design-guidelines', 'web-design-guidelines', NULL, 1, 'other', 0, 0, 1), + ('vercel-labs', 'openreview', 'web-design-guidelines', 'web-design-guidelines', NULL, 1, 'other', 0, 0, 1); + `) + const design = CLUSTERS.find(cluster => cluster.slug === 'design')! + const page = clusterPageSql(COLUMNS, design.categories, parseClusterSkillKeys(design.pinnedExamples), { limit: 60, offset: 0 }) + const rows = sqlite.prepare(page.listSql).all(...page.listParams) as { owner: string, repo: string, name: string }[] + + expect(rows.map(row => `${row.owner}/${row.repo}/${row.name}`)).toEqual([ + 'emilkowalski/skills/emil-design-eng', + 'vercel-labs/agent-skills/web-design-guidelines', + ]) + } + finally { + sqlite.close() + } + }) + it('keeps every live cluster page under the D1 parameter cap', () => { for (const cluster of CLUSTERS) { const page = clusterPageSql( diff --git a/test/unit/cluster-skill-curation.test.ts b/test/unit/cluster-skill-curation.test.ts index fa09204c..f0afb4ba 100644 --- a/test/unit/cluster-skill-curation.test.ts +++ b/test/unit/cluster-skill-curation.test.ts @@ -1,21 +1,21 @@ import { describe, expect, it } from 'vitest' -import { curateClusterSkills } from '../../layers/registry/server/utils/cluster-skill-curation' +import { curateClusterSkills, parseClusterSkillKeys } from '../../layers/registry/server/utils/cluster-skill-curation' const skills = [ - { owner: 'mattpocock', name: 'wait-what' }, - { owner: 'mattpocock', name: 'batch-grill-me' }, - { owner: 'mattpocock', name: 'writing-great-skills' }, - { owner: 'emilkowalski', name: 'animation-vocabulary' }, - { owner: 'dylantarre', name: 'web-motion-design' }, - { owner: 'simota', name: 'palette' }, + { owner: 'mattpocock', repo: 'skills', name: 'wait-what' }, + { owner: 'mattpocock', repo: 'skills', name: 'batch-grill-me' }, + { owner: 'mattpocock', repo: 'skills', name: 'writing-great-skills' }, + { owner: 'emilkowalski', repo: 'skills', name: 'animation-vocabulary' }, + { owner: 'dylantarre', repo: 'animation-principles', name: 'web-motion-design' }, + { owner: 'simota', repo: 'agent-skills', name: 'palette' }, ] describe('cluster skill curation', () => { it('leads with three hand-picked skills from distinct authors', () => { const result = curateClusterSkills(skills, [ - 'emilkowalski/animation-vocabulary', - 'dylantarre/web-motion-design', - 'simota/palette', + 'emilkowalski/skills/animation-vocabulary', + 'dylantarre/animation-principles/web-motion-design', + 'simota/agent-skills/palette', ]) expect(result.slice(0, 3)).toEqual(skills.slice(3)) @@ -23,10 +23,10 @@ describe('cluster skill curation', () => { it('uses another author before repeating a pinned author', () => { const result = curateClusterSkills(skills, [ - 'mattpocock/wait-what', - 'mattpocock/batch-grill-me', - 'emilkowalski/animation-vocabulary', - 'dylantarre/web-motion-design', + 'mattpocock/skills/wait-what', + 'mattpocock/skills/batch-grill-me', + 'emilkowalski/skills/animation-vocabulary', + 'dylantarre/animation-principles/web-motion-design', ]) expect(result.slice(0, 3).map(skill => skill.owner)).toEqual([ @@ -35,4 +35,15 @@ describe('cluster skill curation', () => { 'dylantarre', ]) }) + + it('leads with the pinned repository, not a namesake in another one', () => { + const twin = { owner: 'emilkowalski', repo: 'skill', name: 'emil-design-eng' } + const pick = { owner: 'emilkowalski', repo: 'skills', name: 'emil-design-eng' } + + expect(curateClusterSkills([twin, pick], ['emilkowalski/skills/emil-design-eng'])[0]).toBe(pick) + }) + + it.each(['emilkowalski/emil-design-eng', 'emilkowalski//emil-design-eng', 'a/b/c/d'])('refuses the pin key %s', (key) => { + expect(() => parseClusterSkillKeys([key])).toThrow('expected owner/repo/name') + }) }) diff --git a/test/unit/clusters-taxonomy.test.ts b/test/unit/clusters-taxonomy.test.ts index 1faa3a2a..8bb5ec73 100644 --- a/test/unit/clusters-taxonomy.test.ts +++ b/test/unit/clusters-taxonomy.test.ts @@ -66,9 +66,9 @@ describe('cluster taxonomy', () => { const backend = CLUSTERS.find(cluster => cluster.slug === 'backend-data') expect(backend?.categories).toEqual(['data-modeling', 'security', 'auth']) expect(backend?.pinnedExamples).toEqual(expect.arrayContaining([ - 'supabase/supabase-postgres-best-practices', - 'wshobson/stride-analysis-patterns', - 'better-auth/best-practices', + 'supabase/agent-skills/supabase-postgres-best-practices', + 'wshobson/agents/stride-analysis-patterns', + 'better-auth/skills/best-practices', ])) }) @@ -141,13 +141,4 @@ describe('cluster taxonomy', () => { expect(categories.has(target.slice('/skills/'.length)), `${tag} -> ${target}`).toBe(true) } }) - - it('pins examples as owner/name keys, never owner/repo/name', () => { - // The detail query matches `s.owner || '/' || s.name`, so a three-segment - // key silently pins nothing. - for (const cluster of CLUSTERS) { - for (const key of cluster.pinnedExamples) - expect(key.split('/'), `${cluster.slug}: ${key}`).toHaveLength(2) - } - }) }) diff --git a/test/unit/d1-read-query-plan.test.ts b/test/unit/d1-read-query-plan.test.ts index fc011c1e..9798a126 100644 --- a/test/unit/d1-read-query-plan.test.ts +++ b/test/unit/d1-read-query-plan.test.ts @@ -38,13 +38,13 @@ describe('d1 read query plans', () => { expect(detail).not.toMatch(/SCAN (s|r)\b/) }) - it('finds pinned skills through the owner and name expression index', () => { + it('finds pinned skills through the owner, repo and name expression index', () => { h = createSqliteD1(allMigrations()) - const { sql, params } = clusterMembersSql(COLUMNS, [], ['acme/deploy', 'acme/review']) + const { sql, params } = clusterMembersSql(COLUMNS, [], ['acme/skills/deploy', 'acme/skills/review']) const detail = plan(sql, params) - expect(detail).toContain('idx_skills_owner_name_key') + expect(detail).toContain('idx_skills_owner_repo_name_key') expect(detail).not.toMatch(/SCAN s\b/) }) diff --git a/test/unit/track-board.test.ts b/test/unit/track-board.test.ts index 8ea9ed70..64cccc78 100644 --- a/test/unit/track-board.test.ts +++ b/test/unit/track-board.test.ts @@ -118,6 +118,47 @@ describe('the lists under the talked section', () => { expect(board.sections.at(-1)?.rows.map(row => row.title)).toEqual(['kept']) }) + it('lists a Skill once when the members repeat it', () => { + const pick = member('emil-design-eng', 43_570, { pinned: true }) + const board = trackBoard({ + noun: 'design', + range: 'week', + talked: [], + members: [pick, member('impeccable', 9000, { pinned: true }), pick, member('starred', 100), member('starred', 100)], + clockSeconds: NOW, + }) + + expect(board.sections.map(section => [section.heading, section.rows.map(row => row.title)])).toEqual([ + ['Hand-picked design skills', ['emil-design-eng', 'impeccable']], + ['More design skills, ranked by GitHub stars', ['starred']], + ]) + }) + + it('lists one copy of an owner\'s Skill shipped from two repositories, the pick first', () => { + // `emilkowalski/skill` is the registry identity `emilkowalski/skills` had + // before a rename, and Anthropic ships `frontend-design` from three repositories. + const inRepo = (name: string, owner: string, repo: string, stars: number, pinned = false) => + member(name, stars, { owner, repo, registryPath: `/gh/${owner}/${repo}/${name}`, pinned }) + const board = trackBoard({ + noun: 'design', + range: 'week', + talked: [], + members: [ + inRepo('emil-design-eng', 'emilkowalski', 'skills', 43_570, true), + inRepo('frontend-design', 'anthropics', 'claude-code', 149_278), + inRepo('emil-design-eng', 'emilkowalski', 'skill', 43_570), + inRepo('frontend-design', 'anthropics', 'skills', 179_743), + inRepo('animate', 'emilkowalski', 'skills', 43_570), + ], + clockSeconds: NOW, + }) + + expect(board.sections.map(section => [section.heading, section.rows.map(row => row.subtitle)])).toEqual([ + ['Hand-picked design skills', ['emilkowalski/skills']], + ['More design skills, ranked by GitHub stars', ['anthropics/skills', 'emilkowalski/skills']], + ]) + }) + it('keeps the pin order a person set, and orders the rest by stars', () => { const board = trackBoard({ noun: 'coding', diff --git a/test/unit/track-talked.test.ts b/test/unit/track-talked.test.ts index 0c7985af..4a888ddf 100644 --- a/test/unit/track-talked.test.ts +++ b/test/unit/track-talked.test.ts @@ -81,7 +81,7 @@ describe('a track ranks only its own Skills', () => { for (const [i, skill] of [abstract, indexable, ungated, pinned, elsewhere].entries()) post(`dev${i}`, [skill]) - const ranked = await loadTrendingSkills({ db: db().db, now: NOW, scope: designScope(['d/picked']) }) + const ranked = await loadTrendingSkills({ db: db().db, now: NOW, scope: designScope(['d/skills/picked']) }) expect(ranked.map(skill => skill.slug).sort()).toEqual(['a11y', 'motion', 'picked']) }) From 563216c93bfc33e837e956dd4c6c8e7a3aa05757 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 04:43:14 +1100 Subject: [PATCH 2/5] fix(trending): state the most-starred demotion in the board header The /skills/trending header said rows rank by how many devs talked about each one, but the board moves Skills from the 20 most-starred repositories below every other row. On 2026-10-06 that kept ponytail, which seven devs talked about, off the 30-row board. The header now states the demotion, and the page and the ranking read one shared constant for the count. --- COPY.md | 7 ++++--- layers/marketing/app/pages/skills/trending.vue | 10 +++++++--- shared/server/trending-board.ts | 4 +++- shared/trending-range.ts | 10 ++++++++++ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/COPY.md b/COPY.md index 7cc5d660..474a9e8f 100644 --- a/COPY.md +++ b/COPY.md @@ -90,16 +90,17 @@ Every ranked section says what orders it. ADR-0004 and ADR-0010 set the rules. | Surface | String | | --- | --- | -| Trending header | `Ranked by how many separate devs talked about each one.` | -| Trending header, with star rows | `Ranked by how many separate devs talked about each one. GitHub stars rank the rest of the board.` | +| Trending header | `Ranked by how many separate devs talked about each one. Skills from the 20 most-starred repositories rank lower, so lesser-known skills lead.` | +| Trending header, with star rows | `Ranked by how many separate devs talked about each one. GitHub stars rank the rest of the board. Skills from the 20 most-starred repositories rank lower, so lesser-known skills lead.` | | Trending star row label | `Ranked by GitHub stars` | | Track talked heading | `{Noun} skills devs talked about this week`, or `this month` on the month board | -| Track talked line | The trending header string, unchanged | +| Track talked line | `Ranked by how many separate devs talked about each one.` Track pages skip the demotion (ADR-0010), so the line never states it | | Track pinned heading | `Hand-picked {noun} skills` | | Track stars heading | `More {noun} skills, ranked by GitHub stars`, or `{Noun} skills, ranked by GitHub stars` when no section comes before it | | Track quiet line | `Devs talked about {n} of these skills this week. A list ranked by devs starts at 5.` | `{noun}` is the track's `noun` field in `clusters.ts`, such as `design` or `SEO`. +The 20 in the trending header is `DEMOTED_STARRED_REPOSITORIES` in `shared/trending-range.ts`. The page and the ranking read that one constant. Never call a ranked section top, popular, hot, best, or a leaderboard. ## Discord digest diff --git a/layers/marketing/app/pages/skills/trending.vue b/layers/marketing/app/pages/skills/trending.vue index 32f54843..14751c6a 100644 --- a/layers/marketing/app/pages/skills/trending.vue +++ b/layers/marketing/app/pages/skills/trending.vue @@ -8,6 +8,7 @@ import { githubAvatarProxyUrl } from '#shared/image-proxy' import { relativeDay } from '#shared/trending-post' import { boardPost, + DEMOTED_STARRED_REPOSITORIES, isEvidenced, leaderboardBoardRows, MIN_INDEXABLE_ROWS, @@ -314,10 +315,13 @@ const headerDescription = computed(() => { return 'Repositories from individual creators publishing reusable agent skills, reviewed for eligibility and ranked by GitHub stars.' // Neither the noun nor the period, both of which the heading above now // states. Repeating them cost the line its only job, which is the ranking - // rule, and "say it once" is the brand's own instruction. + // rule, and "say it once" is the brand's own instruction. The demotion is + // part of that rule: it reorders rows, so a line that leaves it out + // overclaims (ADR-0004). + const demoted = `Skills from the ${DEMOTED_STARRED_REPOSITORIES} most-starred repositories rank lower, so lesser-known skills lead.` return fillerTotal.value - ? 'Ranked by how many separate devs talked about each one. GitHub stars rank the rest of the board.' - : 'Ranked by how many separate devs talked about each one.' + ? `Ranked by how many separate devs talked about each one. GitHub stars rank the rest of the board. ${demoted}` + : `Ranked by how many separate devs talked about each one. ${demoted}` }) const heading = computed(() => trendingRangeHeading(range.value, clock.value)) diff --git a/shared/server/trending-board.ts b/shared/server/trending-board.ts index 530aa655..3d199994 100644 --- a/shared/server/trending-board.ts +++ b/shared/server/trending-board.ts @@ -18,6 +18,7 @@ import { loadTrendingRepos, } from '#shared/server/trending-repos' import { loadTrendingSkills } from '#shared/server/trending-skills' +import { DEMOTED_STARRED_REPOSITORIES } from '#shared/trending-range' /** * Top up from GitHub stars when the socials have been quiet. Below this many @@ -46,7 +47,8 @@ export interface LoadTrendingBoardOptions { export async function loadTrendingBoard(options: LoadTrendingBoardOptions): Promise { const { db, now, limit, windowHours } = options - const deprioritizeRepositories = await loadTopStarredRepositories(db, 20) + // The page header states this demotion, so it reads the same constant. + const deprioritizeRepositories = await loadTopStarredRepositories(db, DEMOTED_STARRED_REPOSITORIES) const [entries, namedSkills] = await Promise.all([ loadTrendingRepos({ db, diff --git a/shared/trending-range.ts b/shared/trending-range.ts index 59c0c144..28665657 100644 --- a/shared/trending-range.ts +++ b/shared/trending-range.ts @@ -31,6 +31,16 @@ export const TRENDING_BOARD_LIMIT = 30 */ export const MIN_INDEXABLE_ROWS = 8 +/** + * Repositories the `week` and `month` boards move below every other row, the + * most-starred first. They stay eligible, so new finds lead the board. + * + * Shared because the board header states this rule, and the number in the + * header must be the number the ranking reads. Track pages skip the demotion + * (ADR-0010), so their header never states it. + */ +export const DEMOTED_STARRED_REPOSITORIES = 20 + /** * The range a bare `/skills/trending` serves. * From c4d05b1c86040df1ef0d66c54310c9769465a2b0 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 04:44:30 +1100 Subject: [PATCH 3/5] fix(cli): keep one rose dot in the /cli opening band The /cli hero drew three rose dots: the Braille names pick and both install chips. DESIGN.md allows one rose element per band. The Teach your agent skilld chip keeps its rose dot. The CLI chip takes the quiet prop, and the texture draws its pick in stone, as the home hero does. --- app/components/CliInstallChip.vue | 5 ++++- layers/marketing/app/pages/cli/index.vue | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/components/CliInstallChip.vue b/app/components/CliInstallChip.vue index e8102f0f..8e50a060 100644 --- a/app/components/CliInstallChip.vue +++ b/app/components/CliInstallChip.vue @@ -8,9 +8,11 @@ import CommandChip from './_CommandChip.vue' * beside `SkilldInstallChip` on the `/cli` page, which teaches the Agent to * drive what this one installs. */ -const { surface } = defineProps<{ +const { surface, quiet = false } = defineProps<{ /** The analytics surface, such as `cli-hero`. */ surface: string + /** Ink the dot instead of rose, where another element already spends the band's rose. */ + quiet?: boolean }>() const command = cliGlobalInstallCmd() @@ -25,6 +27,7 @@ const consequenceId = useId() :command="command" mode="install" size="sm" + :quiet="quiet" :surface="surface" :target="target" copy-label="Copy CLI install command" diff --git a/layers/marketing/app/pages/cli/index.vue b/layers/marketing/app/pages/cli/index.vue index 136460c6..6cbcaca9 100644 --- a/layers/marketing/app/pages/cli/index.vue +++ b/layers/marketing/app/pages/cli/index.vue @@ -133,7 +133,9 @@ const codeClass = 'font-mono text-xs text-default'