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
3 changes: 2 additions & 1 deletion .agents/agents/catalogue-auditor.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ Load and apply these skills:
exclusions that cover `_Sidebar.md`/`index.md` stubs, and a non-zero aggregated page count.
4. **Stability** — `status` matches the repository `archived` flag and the NuGet release channel.
5. **Metadata and tags** — repository description present and useful, topics non-empty (they are the
site's tag chips), homepage/link targets correct, `discussions` matches the repository flag.
site's tag chips), homepage/link targets correct, and `discussions` left unset (it is detected
from the repository, so only an explicit override may disagree with it).
6. **Packages** — every declared package id exists on NuGet with published versions, exactly one
`primary`, and `targetFrameworks` are plausible for the repository.
7. **Use cases** — at least one per non-archived project, valid audiences, `code` paired with
Expand Down
4 changes: 2 additions & 2 deletions .agents/agents/catalogue-onboarder.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ The most important rules are:
`category` and `status` from the schema vocabulary (never invent values).
4. **Author** — add the record in the correct `order`, with `docs`, `packages`, `targetFrameworks`,
`install`, `related`/`acknowledgments` where they apply, and at least one use case.
5. **Fix upstream metadata** — set GitHub topics (tags), make the description useful, enable
discussions only when `discussions: true` is declared.
5. **Fix upstream metadata** — set GitHub topics (tags), make the description useful, and leave
`discussions` unset: the site detects it from the repository.
6. **Integrate and prove** — run `just data-sync`, then `just check-projects`, `bun run typecheck`,
`bun run test`, `just check-generated`, and finally `just validate`. Confirm the docs page count,
the sidebar topic, the `/projects/<id>/llms.txt` bundle, and the catalogue/use-case cards.
Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/add-catalogue-project/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ Add the entry as `src/src/data/projects/<id>.yml` (see `project-manifest-referen
evidence: >-
<a fact: before/after, count, generated output, measured property>
docsPage: <slug-of-an-aggregated-page>
discussions: false
```

Every non-archived project needs at least **one** use case with `code` + `language` and an
Expand All @@ -133,7 +132,8 @@ Every non-archived project needs at least **one** use case with `code` + `langua
`github-repo-metadata` for the exact command and the taxonomy guidance.
- The repository description must be genuinely descriptive: it backs the `shortDescription`
fallback and social/`og:` metadata.
- Only set `discussions: true` once GitHub Discussions is actually enabled on the repository.
- Leave `discussions` unset: the loader detects it from the repository (`hasDiscussions`). Set
`discussions: false` only to opt a repository out, or `true` to force the link.

## Step 5 — integrate and verify

Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/github-repo-metadata/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Everything else in the table below is build-time.
| Successor/archived messaging | GitHub **archived** flag + `status`/`supersededBy` | |
| Version numbers, release dates, badges | GitHub releases + NuGet flat-container index | |
| Downloads / deprecated / listed | NuGet search entry | Rendered on the release tables |
| Discussions link | manifest `discussions` **and** repository discussion settings | The loader only builds the URL when `discussions: true` |
| Discussions link | repository discussion settings (detected) | `hasDiscussions` from the cache; a record's `discussions` is only an override |

`search` corpora on `/projects/` deliberately include the topics, so missing topics make a project
undiscoverable by its domain keywords.
Expand Down
7 changes: 4 additions & 3 deletions .agents/skills/project-manifest-reference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,13 @@ Adding a value to any of these lists is a schema change and requires an ADR. `ex
| `install` | no | Default `nuget` |
| `targetFrameworks` | no | e.g. `net8.0`, `net9.0`, `net10.0`, `netstandard2.0`; `just check-projects` reports (advisory) when it does not match the union of the runtime packages' published NuGet TFMs |
| `packages` | no | `{ id, description?, primary?, targetFrameworks? }[]`; defaults to `[]` |
| `skills` | no | `{ name, path?, description? }[]` — Agent Skills the project publishes from `skills/<name>/SKILL.md` (ADR 0010); mirrored to `/skills/<id>/<name>/` and rendered with the shared install block; defaults to `[]` |
| `assets` | no | `{ path, output, description? }[]`; repository files mirrored into `public/` at build time (`path` is repo-relative, `output` is site-relative); defaults to `[]` |
| `related` | no | Known project ids; rendered in the project page aside |
| `useCases` | no | Default `[]`; at least one required for non-archived projects |
| `acknowledgments` | no | `{ name, url, description? }[]`; upstream work the project credits |
| `supersedes` / `supersededBy` | no | Known project ids; used for archived/successor messaging |
| `discussions` | no | Default `false`; when true the page links to repository Discussions (the repository must have them enabled) |
| `discussions` | no | **Detected, not authored**: the page links repository Discussions when the repository has them enabled (`hasDiscussions` in the release cache). Set it only to override — `false` opts a repository out |

## `docs` (a discriminated union on `source`)

Expand Down Expand Up @@ -127,8 +128,8 @@ docs:
## Defaults applied by the loader

`featured: false`, `order: 1000`, `packages: []`, `related: []`, `useCases: []`,
`acknowledgments: []`, `discussions: false`, `install: 'nuget'`, `experimental: false`. Derived values (`repoOwner`,
`repoName`, `sourceUrl`, `issuesUrl`, `discussionsUrl`, `changelogUrl`, `releasesUrl`) are computed,
`acknowledgments: []`, `install: 'nuget'`, `experimental: false`. Derived values (`repoOwner`,
`repoName`, `sourceUrl`, `issuesUrl`, `discussions`, `discussionsUrl`, `changelogUrl`, `releasesUrl`) are computed,
never authored.

## `externalProjects[]` fields
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -699,3 +699,6 @@ sketch
BenchmarkDotNet.Artifacts/

.tools/purview-build/**

# Generated: Agent Skills mirrored from the product repositories by `just data-sync`.
src/public/skills/
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ from the repository root and delegated to the package.
| `src/src/content/docs/**` | Aggregated documentation mirror (gitignored) | `just data-sync` |
| `src/.cache/docs`, `src/.cache/releases` | Typed docs/release caches (gitignored) | `just data-sync` / `just fetch-releases` |
| `src/public/schemas/**` | Mirrored project assets declared in `assets:` (gitignored) | `just data-sync` |
| `src/public/skills/**` | Mirrored Agent Skills declared in `skills:` (gitignored) | `just data-sync` |
| `src/dist/**` | Production build output (gitignored) | `just build` |

### Authoritative validation
Expand Down Expand Up @@ -90,6 +91,11 @@ prints advisory observations (which never fail the build) when a project's decla
automatic: the prerelease channel is withheld once a stable release (GitHub release or NuGet
version) catches up with the prerelease line, and returns with the next prerelease — never
delete the declaration to stop publishing it.
- Agent Skills a project publishes are declared in `skills:` (ADR 0010) at
`skills/<name>/SKILL.md`; the sync mirrors the whole directory to
`/skills/<id>/<name>/` and every page renders the same install block
(`npx skills add <owner>/<repo>`). Never ship a skill in a package or copy it
into an agent directory at build time, and never hand-edit `src/public/skills/`.
- `order` values are unique and control the catalogue order.
- `related`, `supersedes`, `supersededBy` must reference known ids.
- `status` must match reality: an archived repository is `archived`; prerelease-only tooling is
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,31 @@ writes a `noindex` meta-refresh page for each one that points at the live page
with the same slug (GitHub Pages serves files only, so there is no redirect
config to declare).

## Agent skills

Purview projects publish Agent Skills at `skills/<name>/SKILL.md` and deliver
them with the skills CLI (`npx skills add <owner>/<repo>`); nothing ships a
skill in a NuGet package. The catalogue declares the skills a project authors,
`just data-sync` mirrors each whole skill directory to `public/skills/<project>/
<skill>/…`, and the site presents them two ways (ADR 0010):

- `/skills/` lists every skill grouped by project, with the install instructions
and a direct link to each `SKILL.md`;
- `/skills.json` is the machine-readable index (install commands, URLs, files).

The install block is one component (`components/SkillInstall.astro`), rendered
unchanged on `/skills/` and on every project page that declares skills, so the
instructions are identical across the suite:

```yaml
skills:
- name: pm-park
- name: pm-shape
```

`just check-projects` fails when a declared skill has no mirror, and
`just check-generated` fails when one is missing from the build.

## Release data

Release data is fetched at build time — never from the visitor's browser.
Expand Down
67 changes: 67 additions & 0 deletions docs/decisions/0010-agent-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# 0010 — Agent Skills on the portal

- **Status:** Accepted
- **Date:** 2026-03-11
- **Supersedes:** none

## Context

Purview projects publish Agent Skills: guidance an AI coding agent loads on
demand. The ecosystem already agrees on the shape (the engineering playbook's
*Agent Skills Distribution* records it):

- a skill is authored **once**, at `skills/<name>/SKILL.md` in the repository
that owns the knowledge, with optional `references/`, `templates/` and peer
files beside it;
- it reaches consumers by **one** route — the skills CLI
(`npx skills add <owner>/<repo>`), which clones the repository, writes into
each agent's own directories (`-a <agent>`, `-g` for user level) and records
what it installed in `skills-lock.json`;
- nothing packs a skill into a NuGet package, and no build mirrors skills into
an agent directory.

The portal had no skills story at all. `pm-skills` is a pure skills project whose
project page shows only its README, and several product projects author skills of
their own (`aspire-resourcekit`, `results`, …) without the site saying so. A
reader therefore met two different answers ("install the package" on one page,
nothing on another) and could not read a skill without cloning its repository.

## Decision

1. **The catalogue declares the skills a project publishes.** `skills[].name`
(a slug) and an optional `path` (default `skills/<name>`) name the directory;
`description` may override the skill's own front matter. Names are unique per
project and validated by the loader.
2. **The build mirrors every declared skill to purview.dev.**
`just data-sync` reads the repository tree, copies the whole skill directory
to `public/skills/<project>/<skill>/…` and caches it under `.cache/skills/`,
so `https://purview.dev/skills/<project>/<skill>/SKILL.md` (and its
references) is a real, stable URL. `public/skills/` is generated and
gitignored like `public/schemas/`.
3. **One shared instruction block, everywhere.** A single component
(`components/SkillInstall.astro`) renders the command, the `#<ref>` pinning
syntax and the `skills-lock.json` restore note. It is used unchanged on
`/skills/`, on each project page that declares skills, and in
`/skills.json` — so the instructions cannot drift between projects.
4. **`/skills/` is the browse and install page**, grouped by project, and
`/skills.json` is the machine-readable index (name, description, directory,
entry URL, file list, install commands).
5. **Guards, not hopes.** `just check-projects` fails when a declared skill has
no mirror, `just check-generated` fails when a declared skill is missing from
the built site, and the sync fails when a declared skill resolves to no files
at its repository path.

## Consequences

- `pm-skills` gains fourteen declarations; `aspire-resourcekit` and `results` gain
five each, so the same install block now appears on their project pages and on
`/skills/` with their own repositories.
- The site never claims to *host* the CLI source: it serves the skill files. The
CLI still installs from the git repository, which is what records a pinnable
ref; the portal URLs exist so a skill can be read, reviewed or fetched without
a clone.
- `public/skills/**` is generated output. Editing it is as wrong as editing the
docs mirror; `just data-sync` owns it and `just clean` removes it.
- A new skill is a manifest entry plus the repository directory. A skill that
moves breaks the build until the manifest follows, which is the intended
failure mode.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "purview-dev",
"version": "0.7.1",
"version": "0.8.0",
"private": true,
"description": "Purview-Dev public website and unified documentation portal (workspace root).",
"license": "MIT",
Expand Down
27 changes: 27 additions & 0 deletions src/scripts/check-generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { extractDescription } from '../src/lib/docs/frontmatter';
import { loadProjects } from '../src/lib/manifest/load';
import { isReleaseCache, readReleaseCache } from '../src/lib/releases/cache';
import { listMirroredSkills } from '../src/lib/skills';

const DIST = resolve('dist');

Expand Down Expand Up @@ -233,6 +234,31 @@ function validateProjectAssets(): void {
}
}

/**
* Every declared Agent Skill is mirrored from its repository into `public/` and
* copied into the built site (ADR 0010), so a missing mirror means `/skills/`
* and the project page link to a 404.
*/
function validateProjectSkills(): void {
for (const project of loadProjects()) {
const skills = listMirroredSkills(project.id);
if (skills.length === 0) {
if (project.skills.length > 0) {
fail(`No skills are mirrored for "${project.id}" although the manifest declares them.`);
}
continue;
}
for (const skill of skills) {
if (!existsSync(resolve(DIST, 'skills', project.id, skill.name, 'SKILL.md'))) {
fail(
`Missing mirrored skill: skills/${project.id}/${skill.name}/SKILL.md ` +
`(declared by "${project.id}"). Re-run \`just data-sync\`.`,
);
}
}
}
}

async function scanForSecrets(): Promise<void> {
const files = await glob('**/*', { cwd: DIST, onlyFiles: true });
for (const file of files) {
Expand Down Expand Up @@ -281,6 +307,7 @@ async function run(): Promise<number> {

validateProjectLlmsBundles();
validateProjectAssets();
validateProjectSkills();

const llmsFull = readFileSync(resolve(DIST, 'llms-full.txt'), 'utf8');
const llms = readFileSync(resolve(DIST, 'llms.txt'), 'utf8');
Expand Down
41 changes: 30 additions & 11 deletions src/scripts/check-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from '../src/lib/releases/target-frameworks';
import { projectReleaseSummary, projectVersionRollup } from '../src/lib/releases/transform';
import { OWNER } from '../src/lib/site';
import { listMirroredSkills } from '../src/lib/skills';

/**
* Deterministic catalogue guard (ADR 0003).
Expand Down Expand Up @@ -291,6 +292,29 @@ function checkDocs(
return pages;
}

/**
* Declared Agent Skills must be mirrored (ADR 0010). The mirror is what makes a
* skill readable at `purview.dev/skills/<project>/<skill>/SKILL.md`, so a
* declaration without one is a broken install link.
*/
function checkSkills(project: ResolvedProject): void {
// Skills are detected from the repository by the sync, so a project with none
// is normal. Only an explicit declaration promises a mirror.
if (project.skills.length === 0) {
return;
}
if (listMirroredSkills(project.id).length === 0) {
fail({
project: project.id,
field: 'skills',
observed: 'no mirrored skills',
expected: `at least one skill under public/skills/${project.id}/`,
remediation:
"run `just data-sync` (live) so the skills are discovered at the project's documentation ref, or check that `skills/` exists in the repository.",
});
}
}

/**
* The `experimental` flag is an intent marker, so it stays orthogonal to the
* release channel (ADR 0004). The one combination that is legal but worth a
Expand Down Expand Up @@ -642,22 +666,16 @@ function checkRepoMetadata(project: ResolvedProject, data: ReleaseCacheData): vo
});
}

// Discussions are detected from the repository, so a resolved `true` can only
// come from an explicit override that disagrees with the repository.
if (project.discussions && !repo.hasDiscussions) {
fail({
project: project.id,
field: 'discussions',
observed: 'discussions: true',
observed: 'discussions: true (explicit override)',
expected: 'GitHub Discussions enabled on the repository',
remediation: 'enable Discussions, or set `discussions: false`.',
});
}
if (!project.discussions && repo.hasDiscussions && project.status !== 'archived') {
warn({
project: project.id,
field: 'discussions',
observed: 'repository Discussions enabled, manifest links none',
expected: 'consider `discussions: true`',
remediation: `set \`discussions: true\` to surface ${project.repository}/discussions.`,
remediation:
'remove the override so the link is detected automatically, or enable Discussions on the repository.',
});
}
}
Expand Down Expand Up @@ -729,6 +747,7 @@ export function checkProjects(): { errors: Finding[]; warnings: Finding[] } {
for (const project of projects) {
const docsPages = checkDocs(project, docsManifest, data);
checkUseCases(project, docsPages);
checkSkills(project);
checkExperimental(project);
if (data) {
checkPackages(project, data);
Expand Down
1 change: 1 addition & 0 deletions src/scripts/clean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const TARGETS = [
'public/icon-512.png',
'public/og',
'public/schemas',
'public/skills',
];

if (import.meta.main) {
Expand Down
13 changes: 8 additions & 5 deletions src/scripts/data-sync.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';

import { readReleaseCache, readReleaseFixture, writeReleaseCache } from '../src/lib/releases/cache';
import { readReleaseCache, readReleaseFixture } from '../src/lib/releases/cache';
import { publishReleaseData } from '../src/lib/releases/runtime';
import { fetchReleaseData } from './fetch-releases';
import { processBranding } from './process-branding';
import { syncAssets } from './sync-assets';
import { hasCurrentDocsMirror, hasDocsMirror, syncDocs } from './sync-docs';
import { syncSkills } from './sync-skills';

const RELEASES_CACHE_FILE = resolve('.cache/releases/releases.json');

Expand Down Expand Up @@ -39,8 +41,9 @@ async function syncReleases(mode: Mode): Promise<void> {
return;
}
if (mode === 'live') {
const data = await fetchReleaseData();
writeReleaseCache(data);
// `publishReleaseData` also refreshes the memoized index: the fetch resolved
// the repository/package list through `loadProjects`, which snapshots it.
publishReleaseData(await fetchReleaseData());
console.log('Releases: fetched live data.');
return;
}
Expand All @@ -50,8 +53,7 @@ async function syncReleases(mode: Mode): Promise<void> {
return;
}
try {
const data = await fetchReleaseData();
writeReleaseCache(data);
publishReleaseData(await fetchReleaseData());
console.log('Releases: fetched live data.');
return;
} catch (error) {
Expand Down Expand Up @@ -102,6 +104,7 @@ export async function dataSync(): Promise<void> {
await syncReleases(mode);
await syncDocsMirror(mode);
await syncAssets(mode);
await syncSkills(mode);
if (!existsSync(RELEASES_CACHE_FILE) && !readReleaseFixture('index')) {
console.warn(
'Note: release data is unavailable. The releases page will be empty until `just fetch-releases` runs.',
Expand Down
Loading
Loading