diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..acecea34b --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "traxsharp", + "owner": { + "name": "TraxSharp", + "url": "https://github.com/TraxSharp" + }, + "metadata": { + "description": "Agent skills for building apps on Trax .NET" + }, + "plugins": [ + { + "name": "trax", + "description": "Agent skills for building apps on Trax .NET", + "source": { + "source": "github", + "repo": "TraxSharp/Trax", + "ref": "skills" + } + } + ] +} diff --git a/.claude/skills/recording-decisions/SKILL.md b/.claude/skills/recording-decisions/SKILL.md index ea335f621..108690af2 100644 --- a/.claude/skills/recording-decisions/SKILL.md +++ b/.claude/skills/recording-decisions/SKILL.md @@ -1,6 +1,8 @@ --- name: recording-decisions description: Record an architecture decision as an ADR in the Trax repository. Use when a change makes a decision that is hard to reverse, surprising without context, and the result of a real trade-off, or when editing, superseding or reviewing an existing ADR. +metadata: + internal: true --- # Recording decisions diff --git a/.github/scripts/publish-skills.sh b/.github/scripts/publish-skills.sh new file mode 100755 index 000000000..1e0cea896 --- /dev/null +++ b/.github/scripts/publish-skills.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Commits a staged skills tree on top of the live branch, to a staging branch, for a maintainer to +# approve (.github/workflows/skills.yml; Trax.Docs/adr/0056). +# +# publish-skills.sh +# +# The staging branch is reset to the live branch's head and the tree is committed on it through +# GraphQL createCommitOnBranch, so GitHub signs the commit and the repository's verified-signatures +# rule holds without a key in this job. The tree replaces what is there: a file the release no +# longer stages is deleted. The commit's parent is the live head, so moving the live branch to it +# is a fast-forward. Prints what changes, and writes `commit=` to $GITHUB_OUTPUT: the new +# commit, or the live head when the tree is already live. +# +# GH_TOKEN must be able to write the staging branch. It cannot write the live branch, whose ruleset +# refuses it; the publish job does that with a deploy key once approved. +set -euo pipefail + +dir=$1 +repo=$2 +live=$3 +staging=$4 +headline=$5 +work=$(mktemp -d) +out=${GITHUB_OUTPUT:-/dev/null} +summary=${GITHUB_STEP_SUMMARY:-/dev/null} + +if ! head=$(gh api "repos/$repo/git/ref/heads/$live" --jq .object.sha 2>/dev/null); then + echo "::error::$repo has no $live branch. Create it once with a signed commit (see the ADR) and re-run." + exit 1 +fi +tree=$(gh api "repos/$repo/git/commits/$head" --jq .tree.sha) + +gh api "repos/$repo/git/trees/$tree?recursive=1" \ + --jq '.tree[] | select(.type == "blob") | .path + "\t" + .sha' | sort > "$work/existing" +(cd "$dir" && find . -type f -printf '%P\n' | sort | while IFS= read -r f; do + printf '%s\t%s\n' "$f" "$(git hash-object -- "$f")" +done) > "$work/staged" + +if cmp -s "$work/existing" "$work/staged"; then + echo "$repo $live already holds this tree; publishing tags $head." + echo "commit=$head" >> "$out" + exit 0 +fi + +# What the approver is asked to accept, by path. +tick='`' +{ + echo "### Changes to $tick$live$tick" + echo + comm -13 "$work/existing" "$work/staged" | cut -f1 | while IFS= read -r p; do echo "- added or changed: $tick$p$tick"; done + comm -23 <(cut -f1 "$work/existing") <(cut -f1 "$work/staged") | while IFS= read -r p; do echo "- removed: $tick$p$tick"; done +} | tee -a "$summary" + +# Start the staging branch at the live head, creating it the first time. +if gh api "repos/$repo/git/ref/heads/$staging" --silent 2>/dev/null; then + gh api -X PATCH "repos/$repo/git/refs/heads/$staging" -f sha="$head" -F force=true --silent +else + gh api "repos/$repo/git/refs" -f ref="refs/heads/$staging" -f sha="$head" --silent +fi + +cut -f1 "$work/staged" | while IFS= read -r f; do + jq -n --arg path "$f" --rawfile contents <(base64 -w0 -- "$dir/$f") '{path: $path, contents: $contents}' +done | jq -s . > "$work/additions.json" +comm -23 <(cut -f1 "$work/existing") <(cut -f1 "$work/staged") \ + | jq -R '{path: .}' | jq -s . > "$work/deletions.json" + +jq -n \ + --arg repo "$repo" --arg branch "$staging" --arg head "$head" --arg headline "$headline" \ + --slurpfile additions "$work/additions.json" --slurpfile deletions "$work/deletions.json" \ + '{ + query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }", + variables: { input: { + branch: { repositoryNameWithOwner: $repo, branchName: $branch }, + expectedHeadOid: $head, + message: { headline: $headline }, + fileChanges: { additions: $additions[0], deletions: $deletions[0] } + } } + }' > "$work/body.json" + +commit=$(gh api graphql --input "$work/body.json" --jq .data.createCommitOnBranch.commit.oid) +if [ -z "$commit" ] || [ "$commit" = null ]; then + echo "::error::createCommitOnBranch returned no commit for $repo $staging." + exit 1 +fi +echo "Committed $commit to $repo $staging, on top of $live ($head)." +echo "Compare: https://github.com/$repo/compare/$head...$commit" | tee -a "$summary" +echo "commit=$commit" >> "$out" diff --git a/.github/workflows/skills.yml b/.github/workflows/skills.yml new file mode 100644 index 000000000..f46e1cc74 --- /dev/null +++ b/.github/workflows/skills.yml @@ -0,0 +1,208 @@ +name: Skills + +# Publishes the agent skills (Trax.Docs/skills/) to the `skills` branch, which Claude Code's `trax` +# plugin and `npx skills add .../tree/skills` install from +# (Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md). +# +# Dispatch it by hand after a release, with the tag the release cut, the way website.yml publishes +# the site. Three jobs, each holding only what it needs: +# +# stage builds Trax.Skills.Publish from the tag and stages the tree: the source marker removed, +# the release's version stamped in. Read-only. +# prepare commits that tree to `skills-next`, on top of `skills`, through createCommitOnBranch, +# so GitHub signs it. Its token can write contents; the commit is a preview, not live. +# publish waits for approval on the `skills-deploy` environment, then fast-forwards `skills` to +# exactly the commit prepare made and tags it skills-v, with the deploy key. +# +# `skills` and `skills-v*` are locked by rulesets whose only bypass is deploy keys, and the key is a +# `skills-deploy` environment secret, so an approved run of this workflow is the only way to change +# what consumers install. Before approving, read the diff prepare prints between `skills` and the +# commit it made. +on: + workflow_dispatch: + inputs: + ref: + description: 'The release tag to publish, e.g. v1.70.0.' + type: string + required: true + +concurrency: + group: skills + cancel-in-progress: false + +permissions: {} + +jobs: + stage: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + outputs: + version: ${{ steps.ref.outputs.version }} + digests: ${{ steps.digests.outputs.digests }} + steps: + - name: Harden runner + uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: block + disable-telemetry: true + allowed-endpoints: > + github.com:443 + api.github.com:443 + release-assets.githubusercontent.com:443 + objects.githubusercontent.com:443 + aka.ms:443 + builds.dotnet.microsoft.com:443 + ci.dot.net:443 + dot.net:443 + api.nuget.org:443 + - name: Only a release tag + id: ref + env: + REF: ${{ inputs.ref }} + run: | + # Skills describe released packages, so main is not accepted, unlike the site. + if ! [[ "$REF" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "::error::'$REF' is not a release tag (vX.Y.Z)." + exit 1 + fi + echo "version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + - name: Checkout the tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ inputs.ref }} + persist-credentials: false + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + - name: Stage the skills + env: + VERSION: ${{ steps.ref.outputs.version }} + run: | + dotnet restore Trax.Docs/tools/Trax.Skills.Publish --locked-mode + dotnet run --project Trax.Docs/tools/Trax.Skills.Publish --configuration Release --no-restore -- \ + stage --skills Trax.Docs/skills --version "$VERSION" --license LICENSE --out skills + - name: Record digests + # Handed to prepare as a job output rather than inside the artifact, so prepare can tell + # whether what it downloaded is what this job staged. + id: digests + run: echo "digests=$(cd skills && find . -type f -printf '%P\n' | LC_ALL=C sort | xargs -d '\n' sha256sum -- | base64 -w0)" >> "$GITHUB_OUTPUT" + - name: Upload skills artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: skills + path: skills/ + # .claude-plugin/ holds the plugin manifest. + include-hidden-files: true + if-no-files-found: error + retention-days: 1 + + prepare: + needs: stage + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # skills-next only; skills itself refuses this token + outputs: + commit: ${{ steps.commit.outputs.commit }} + steps: + - name: Harden runner + uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: block + disable-telemetry: true + allowed-endpoints: > + github.com:443 + api.github.com:443 + release-assets.githubusercontent.com:443 + objects.githubusercontent.com:443 + - name: Checkout the scripts + # For the publish script only; nothing in it is built or run. + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ inputs.ref }} + persist-credentials: false + sparse-checkout: .github/scripts + - name: Download skills artifact + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: skills + path: skills + - name: Verify the skills are the ones stage staged + env: + DIGESTS: ${{ needs.stage.outputs.digests }} + run: | + echo "$DIGESTS" | base64 -d > "$RUNNER_TEMP/expected-skills.sha256" + if [ "$(cd skills && find . -type f -printf '%P\n' | LC_ALL=C sort)" != "$(awk '{print $2}' "$RUNNER_TEMP/expected-skills.sha256")" ]; then + echo "::error::The downloaded skills are not the set the stage job staged." + exit 1 + fi + (cd skills && sha256sum --check --strict "$RUNNER_TEMP/expected-skills.sha256") + - name: Commit to skills-next + id: commit + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.stage.outputs.version }} + run: .github/scripts/publish-skills.sh skills "$GITHUB_REPOSITORY" skills skills-next "Trax $VERSION" + + publish: + needs: [stage, prepare] + runs-on: ubuntu-latest + timeout-minutes: 5 + # Waits here for a maintainer's approval. + environment: skills-deploy + # The push authenticates with the deploy key, not the workflow token. + permissions: {} + steps: + - name: Harden runner + uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: block + disable-telemetry: true + allowed-endpoints: > + github.com:443 + api.github.com:443 + github.com:22 + - name: Move the skills branch and tag it + env: + SHA: ${{ needs.prepare.outputs.commit }} + VERSION: ${{ needs.stage.outputs.version }} + DEPLOY_KEY: ${{ secrets.SKILLS_DEPLOY_KEY }} + # github.com's published ed25519 host key (https://api.github.com/meta), pinned so the key is + # only ever offered to GitHub. + GITHUB_HOST_KEY: github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl + run: | + if ! [[ "$SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::prepare handed on '$SHA', not a commit." + exit 1 + fi + if [ -z "$DEPLOY_KEY" ]; then + echo "::error::SKILLS_DEPLOY_KEY is not set on the skills-deploy environment." + exit 1 + fi + + umask 077 + printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key" + printf '%s\n' "$GITHUB_HOST_KEY" > "$RUNNER_TEMP/known_hosts" + export GIT_SSH_COMMAND="ssh -i $RUNNER_TEMP/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$RUNNER_TEMP/known_hosts" + remote="git@github.com:$GITHUB_REPOSITORY.git" + tag="skills-v$VERSION" + + git init -q "$RUNNER_TEMP/skills" + cd "$RUNNER_TEMP/skills" + git fetch -q --depth=2 "$remote" "$SHA" + + # A tag is never moved: one that exists must already name this commit. + existing=$(git ls-remote "$remote" "refs/tags/$tag" | cut -f1) + if [ -n "$existing" ] && [ "$existing" != "$SHA" ]; then + echo "::error::$tag already names $existing, not $SHA. Find out who created it." + exit 1 + fi + + # Not forced: prepare built the commit on the current skills, so this is a fast-forward, + # and if skills moved since, the push is refused rather than overwriting it. + git push "$remote" "$SHA:refs/heads/skills" "$SHA:refs/tags/$tag" + rm -f "$RUNNER_TEMP/deploy_key" + echo "skills and $tag now name $SHA." diff --git a/Trax.Docs/README.md b/Trax.Docs/README.md index 46252479b..f7dd0d033 100644 --- a/Trax.Docs/README.md +++ b/Trax.Docs/README.md @@ -16,9 +16,11 @@ decision records for the rules that span more than one folder of the [Trax repos |---|---| | `*.md` and the section directories (`core/`, `effect/`, `mediator/`, `scheduler/`, `statemachine/`, `sdk-reference/`, `reference/`, `contributing/`, ...) | The pages published at traxsharp.net/docs. A file at `effect/metadata.md` is served at `/docs/effect/metadata`. | | `adr/` | Architecture decision records that bind more than one folder, indexed in [`adr/README.md`](https://github.com/TraxSharp/Trax/blob/main/Trax.Docs/adr/README.md). Not published to the site. | +| `skills/` | Agent skills for apps built on Trax, one folder per skill. Not published to the site; after each release an approved run of the `Skills` workflow publishes them to the [`skills` branch](https://github.com/TraxSharp/Trax/tree/skills) ([ADR 0056](https://github.com/TraxSharp/Trax/blob/main/Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md)). | | `tools/Trax.Adr.Guard` | The checker for ADR frontmatter, index tables and guard census. CI builds it from this folder in the same checkout and runs it over each folder's ADR corpus through the local composite action `.github/actions/adr-guard` at the repository root, so a guard change and the corpus it checks land in one pull request ([ADR 0042](https://github.com/TraxSharp/Trax/blob/main/Trax.Docs/adr/0042-trax-is-one-repository-and-releases-at-one-version.md)). | | `tests/Trax.Docs.Tests` | Lint for the pages: internal links and redirects resolve, no em dashes, no Jekyll syntax, every page has a type and an area, SDK reference pages name a package, namespace and source that exist, and links from the code to the docs resolve. | -| `tests/Trax.Docs.Snippets.Tests` | The code on the pages: every C# fence is marked, `compile` fences build against this checkout, `from=` fences match the file they quote. | +| `tests/Trax.Docs.Snippets.Tests` | The code on the pages: every C# fence is marked, `compile` fences build against this checkout, `from=` fences match the file they quote. Covers the skills' fences too. | +| `tools/Trax.Skills.Publish` | Checks the skills and stages the tree the `Skills` workflow commits to the `skills` branch: the release's version stamped in, the plugin manifest written. Its `marketplace` command prints the `.claude-plugin/marketplace.json` that `main` holds. Tested by `tests/Trax.Skills.Publish.Tests`. | [Trax.Website](https://github.com/TraxSharp/Trax/tree/main/Trax.Website) renders these files. The site is published from a release: the release workflow moves the `website` branch to each release tag, so the published docs describe released code diff --git a/Trax.Docs/Trax.Docs.slnx b/Trax.Docs/Trax.Docs.slnx index 47fe718ce..c6ce3dca7 100644 --- a/Trax.Docs/Trax.Docs.slnx +++ b/Trax.Docs/Trax.Docs.slnx @@ -3,8 +3,10 @@ + + diff --git a/Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md b/Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md new file mode 100644 index 000000000..2f17939ce --- /dev/null +++ b/Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md @@ -0,0 +1,86 @@ +--- +authors: [Theauxm] +repos: [docs, samples, website] +areas: [docs, packaging] +status: accepted +--- + +# Agent skills are written in the docs and published from a release + +Coding agents working in an app built on Trax get skills: short instructions an agent loads when a task matches, +saying what to decide first, which page to read and which mistakes are known. They are written in +`Trax.Docs/skills//SKILL.md`, and after each release a maintainer-approved run publishes them to the `skills` +branch of this repository, stamped with the release's version. Nothing serves them from `main`. + +## Status + +**Accepted.** + +## Decisions + +- **Written beside the docs, held to the docs' rules.** A skill routes to pages and holds what the pages do not: + ordering, decisions and traps. Its C# fences carry the same `compile`, `from=` and `fragment` markers as a page and + are checked the same way, so a skill that teaches an API the code no longer has fails the build. `skills/` is not + published as pages (`sync-docs.sh` skips it). +- **Every source skill is marked `metadata.internal: true`.** The skills CLI reads any public repository, and + `npx skills add TraxSharp/Trax` must not offer what `main` holds before a release ships it. Measured with skills + 1.7.4 (2026-10-20): with only the contributor skill in `.claude/skills/` marked internal, the CLI finds no skill in + its usual places, falls back to a recursive search and offers everything under `Trax.Docs/skills/`; with every + skill marked, it offers nothing, `--full-depth` included. Claude Code ignores the key. +- **They are published to the `skills` branch, from a release tag, when a maintainer approves.** The `Skills` + workflow is dispatched by hand after a release, with its tag, the way `website.yml` publishes the site + ([0043](./0043-the-site-is-published-from-a-release.md)). It stages the tree (the marker removed, + `metadata.trax-version` stamped in, the Claude Code plugin manifest `trax` at that version), commits it on top of + `skills` to `skills-next`, and waits on the `skills-deploy` environment; once approved, it fast-forwards `skills` to + that commit and tags it `skills-v` (`v*` is the packages' tag). A static marketplace (`traxsharp`) at + `.claude-plugin/marketplace.json` on `main` names the plugin with the branch as its source, so Claude Code installs + with `/plugin marketplace add TraxSharp/Trax` and `/plugin install trax@traxsharp`, and updates follow what was + approved. Other agents install the branch with `npx skills add https://github.com/TraxSharp/Trax/tree/skills`; the + skills CLI does not follow a marketplace to another ref (measured). An install from a `skills-v` tree URL + records the ref in `skills-lock.json` and stays on it, which is how an app on an older Trax keeps matching skills. +- **Only an approved run moves the branch.** A skill is instructions an agent follows in a consumer's repository, so + write access to `skills` is a way to instruct every consumer's agent. Its ruleset refuses updates, deletion and + force pushes to all but deploy keys, and `skills-v*` tags the same (creation included); the key is a `skills-deploy` + environment secret, released only by a maintainer's approval. The commit itself is made through the API, so GitHub + signs it; a deploy key cannot push a commit GitHub would verify, which is why the work is split: the workflow token + writes the signed commit to `skills-next`, which nobody installs from, and the key only moves `skills` to it. A + ruleset cannot name the workflow token as its bypass, so a single job could not be both the writer and the only + writer. A skill ships markdown only, never a script. + +## Considered options + +**A separate repository, `TraxSharp/skills`, written by a GitHub App.** The first version of this decision. It gives +non-Claude agents the shortest install (`npx skills add TraxSharp/skills`) and keeps generated history out of this +repository. Rejected once the internal marker turned out to be needed anyway: with it, `main` serves nothing, so the +second repository only bought a shorter URL, at the cost of an App, its private key and a repository to keep up. + +**Publishing from the release itself.** Automatic, but nothing a ruleset accepts would then stop any other workflow +holding `contents: write` from moving the branch, and nobody reviews what changes. Rejected for the approved +deploy, which matches how the site is published. + +**`skills/` at the root of `main`.** The CLI's default place. Rejected: the obvious command would install from `main`, +ahead of what is on nuget.org. + +**A Claude Code plugin only.** Rejected: people building on Trax use other agents too, and the skills CLI installs +into most of them from the same files. + +**Copies in every template.** Version-matched for free, but `npx skills update` never refreshes a copy, so every +project would drift from its own packages after the first bump. Templates point at the published skills instead. + +## Exemplars + +- `SkillsAreWellFormedTests` holds every skill to the release's rules (name, description, the internal marker, no + stamped version, markdown only, links inside the skill), every docs link in a skill to a published page, and the + marketplace on `main` to the one the release expects. +- `Trax.Docs/tools/Trax.Skills.Publish` is the stage the `Skills` workflow runs; it applies the same rules and refuses to + write a tree that breaks one. Its tests are in `StagerTests.cs` and `SkillRulesTests.cs`. +- `CompiledSnippetsTests.cs` and `EveryCSharpFenceIsMarkedTests.cs` in Trax.Docs.Snippets.Tests cover a skill's + fences as they cover a page's. + +Not covered: nothing checks a skill's advice is right, only that its code builds and its links land, and nothing here +checks that only the approved deploy moves the `skills` branch; that is its ruleset and the `skills-deploy` +environment. + +## Changelog + +- **2026-10-20**: Recorded. diff --git a/Trax.Docs/adr/README.md b/Trax.Docs/adr/README.md index 3cb7f9067..b283d8266 100644 --- a/Trax.Docs/adr/README.md +++ b/Trax.Docs/adr/README.md @@ -50,24 +50,24 @@ realising. A repo-scoped ADR omits the key, because its path already says it. | `cli` | [0001](./0001-architectural-rules-are-executable-guards.md), [0002](./0002-cross-repo-dependencies-are-exact-pinned.md), [0003](./0003-a-repo-depends-only-on-what-is-upstream.md), [0004](./0004-tests-assert-with-fluentassertions.md), [0005](./0005-a-skipped-test-is-a-runtime-decision.md), [0006](./0006-tests-synchronise-on-a-signal.md), [0008](./0008-documentation-conventions-are-linted.md), [0010](./0010-the-public-api-surface-is-a-committed-baseline.md), [0011](./0011-test-frameworks-stay-out-of-shipped-libraries.md), [0012](./0012-an-adrs-exemplars-are-declared-by-attribute.md), [0013](./0013-trax-owns-the-vocabulary-for-its-own-concepts.md), [0014](./0014-a-test-owns-every-timeout-it-waits-behind.md), [0015](./0015-every-packageversion-names-a-referenced-package.md), [0016](./0016-a-junction-chain-is-a-declaration-not-a-step-of-the-work.md), [0032](./0032-a-warning-fails-the-ci-build.md), [0038](./0038-the-adr-guard-is-released-by-tag.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0043](./0043-the-site-is-published-from-a-release.md), [0044](./0044-property-tests-use-cscheck-in-test-projects-only.md), [0046](./0046-a-machine-state-invokes-a-train-and-only-that-entry-receives-its-outcome.md), [0048](./0048-a-machine-is-served-as-a-typed-api-and-the-server-applies-every-transition.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md), [0051](./0051-only-the-caller-who-started-a-run-may-follow-its-progress.md), [0053](./0053-a-client-document-is-registered-by-the-host-at-boot.md), [0054](./0054-a-frontend-writes-every-document-that-reaches-a-train.md) | | `core` | [0001](./0001-architectural-rules-are-executable-guards.md), [0002](./0002-cross-repo-dependencies-are-exact-pinned.md), [0003](./0003-a-repo-depends-only-on-what-is-upstream.md), [0004](./0004-tests-assert-with-fluentassertions.md), [0005](./0005-a-skipped-test-is-a-runtime-decision.md), [0006](./0006-tests-synchronise-on-a-signal.md), [0008](./0008-documentation-conventions-are-linted.md), [0010](./0010-the-public-api-surface-is-a-committed-baseline.md), [0011](./0011-test-frameworks-stay-out-of-shipped-libraries.md), [0012](./0012-an-adrs-exemplars-are-declared-by-attribute.md), [0013](./0013-trax-owns-the-vocabulary-for-its-own-concepts.md), [0014](./0014-a-test-owns-every-timeout-it-waits-behind.md), [0015](./0015-every-packageversion-names-a-referenced-package.md), [0016](./0016-a-junction-chain-is-a-declaration-not-a-step-of-the-work.md), [0020](./0020-a-failure-is-classified-where-it-happens-and-carried.md), [0032](./0032-a-warning-fails-the-ci-build.md), [0033](./0033-a-package-validates-against-its-last-release.md), [0038](./0038-the-adr-guard-is-released-by-tag.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0040](./0040-a-chain-declares-every-track-and-a-decider-chooses-one.md), [0041](./0041-a-requeued-run-replays-the-decisions-of-the-run-it-repeats.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0043](./0043-the-site-is-published-from-a-release.md), [0044](./0044-property-tests-use-cscheck-in-test-projects-only.md), [0045](./0045-a-parallel-step-runs-fixed-branches-on-copies-of-memory-and-the-join-commits.md), [0046](./0046-a-machine-state-invokes-a-train-and-only-that-entry-receives-its-outcome.md), [0047](./0047-a-checkpoint-stores-a-state-the-train-declares-and-a-resume-skips-to-it.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md) | | `dashboard` | [0001](./0001-architectural-rules-are-executable-guards.md), [0002](./0002-cross-repo-dependencies-are-exact-pinned.md), [0003](./0003-a-repo-depends-only-on-what-is-upstream.md), [0004](./0004-tests-assert-with-fluentassertions.md), [0005](./0005-a-skipped-test-is-a-runtime-decision.md), [0006](./0006-tests-synchronise-on-a-signal.md), [0007](./0007-the-canonical-train-name-is-the-interface-fullname.md), [0008](./0008-documentation-conventions-are-linted.md), [0010](./0010-the-public-api-surface-is-a-committed-baseline.md), [0011](./0011-test-frameworks-stay-out-of-shipped-libraries.md), [0012](./0012-an-adrs-exemplars-are-declared-by-attribute.md), [0013](./0013-trax-owns-the-vocabulary-for-its-own-concepts.md), [0014](./0014-a-test-owns-every-timeout-it-waits-behind.md), [0015](./0015-every-packageversion-names-a-referenced-package.md), [0016](./0016-a-junction-chain-is-a-declaration-not-a-step-of-the-work.md), [0017](./0017-a-callers-enqueue-goes-through-the-mediator.md), [0018](./0018-a-deferred-enqueue-is-staged-and-a-stranded-one-is-cancelled.md), [0019](./0019-queued-work-for-one-subject-runs-one-at-a-time.md), [0020](./0020-a-failure-is-classified-where-it-happens-and-carried.md), [0022](./0022-the-dashboard-and-the-api-share-one-operation-per-action.md), [0023](./0023-caller-supplied-train-input-is-read-case-insensitively.md), [0032](./0032-a-warning-fails-the-ci-build.md), [0033](./0033-a-package-validates-against-its-last-release.md), [0037](./0037-run-now-applies-the-same-per-record-checks-as-queueing.md), [0038](./0038-the-adr-guard-is-released-by-tag.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0040](./0040-a-chain-declares-every-track-and-a-decider-chooses-one.md), [0041](./0041-a-requeued-run-replays-the-decisions-of-the-run-it-repeats.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0043](./0043-the-site-is-published-from-a-release.md), [0044](./0044-property-tests-use-cscheck-in-test-projects-only.md), [0045](./0045-a-parallel-step-runs-fixed-branches-on-copies-of-memory-and-the-join-commits.md), [0046](./0046-a-machine-state-invokes-a-train-and-only-that-entry-receives-its-outcome.md), [0047](./0047-a-checkpoint-stores-a-state-the-train-declares-and-a-resume-skips-to-it.md), [0048](./0048-a-machine-is-served-as-a-typed-api-and-the-server-applies-every-transition.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md), [0055](./0055-the-react-console-is-the-operator-dashboard-and-the-blazor-one-is-removed.md) | -| `docs` | [0004](./0004-tests-assert-with-fluentassertions.md), [0005](./0005-a-skipped-test-is-a-runtime-decision.md), [0008](./0008-documentation-conventions-are-linted.md), [0012](./0012-an-adrs-exemplars-are-declared-by-attribute.md), [0032](./0032-a-warning-fails-the-ci-build.md), [0038](./0038-the-adr-guard-is-released-by-tag.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0043](./0043-the-site-is-published-from-a-release.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md), [0055](./0055-the-react-console-is-the-operator-dashboard-and-the-blazor-one-is-removed.md) | +| `docs` | [0004](./0004-tests-assert-with-fluentassertions.md), [0005](./0005-a-skipped-test-is-a-runtime-decision.md), [0008](./0008-documentation-conventions-are-linted.md), [0012](./0012-an-adrs-exemplars-are-declared-by-attribute.md), [0032](./0032-a-warning-fails-the-ci-build.md), [0038](./0038-the-adr-guard-is-released-by-tag.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0043](./0043-the-site-is-published-from-a-release.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md), [0055](./0055-the-react-console-is-the-operator-dashboard-and-the-blazor-one-is-removed.md), [0056](./0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md) | | `effect` | [0001](./0001-architectural-rules-are-executable-guards.md), [0002](./0002-cross-repo-dependencies-are-exact-pinned.md), [0003](./0003-a-repo-depends-only-on-what-is-upstream.md), [0004](./0004-tests-assert-with-fluentassertions.md), [0005](./0005-a-skipped-test-is-a-runtime-decision.md), [0006](./0006-tests-synchronise-on-a-signal.md), [0007](./0007-the-canonical-train-name-is-the-interface-fullname.md), [0008](./0008-documentation-conventions-are-linted.md), [0009](./0009-feature-tables-ship-in-the-core-provider-set.md), [0010](./0010-the-public-api-surface-is-a-committed-baseline.md), [0011](./0011-test-frameworks-stay-out-of-shipped-libraries.md), [0012](./0012-an-adrs-exemplars-are-declared-by-attribute.md), [0013](./0013-trax-owns-the-vocabulary-for-its-own-concepts.md), [0014](./0014-a-test-owns-every-timeout-it-waits-behind.md), [0015](./0015-every-packageversion-names-a-referenced-package.md), [0016](./0016-a-junction-chain-is-a-declaration-not-a-step-of-the-work.md), [0018](./0018-a-deferred-enqueue-is-staged-and-a-stranded-one-is-cancelled.md), [0019](./0019-queued-work-for-one-subject-runs-one-at-a-time.md), [0020](./0020-a-failure-is-classified-where-it-happens-and-carried.md), [0021](./0021-a-queue-hook-reads-its-input-through-traininput.md), [0032](./0032-a-warning-fails-the-ci-build.md), [0033](./0033-a-package-validates-against-its-last-release.md), [0036](./0036-a-feature-table-ships-with-its-model-in-effect.md), [0038](./0038-the-adr-guard-is-released-by-tag.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0040](./0040-a-chain-declares-every-track-and-a-decider-chooses-one.md), [0041](./0041-a-requeued-run-replays-the-decisions-of-the-run-it-repeats.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0043](./0043-the-site-is-published-from-a-release.md), [0044](./0044-property-tests-use-cscheck-in-test-projects-only.md), [0045](./0045-a-parallel-step-runs-fixed-branches-on-copies-of-memory-and-the-join-commits.md), [0046](./0046-a-machine-state-invokes-a-train-and-only-that-entry-receives-its-outcome.md), [0047](./0047-a-checkpoint-stores-a-state-the-train-declares-and-a-resume-skips-to-it.md), [0048](./0048-a-machine-is-served-as-a-typed-api-and-the-server-applies-every-transition.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md), [0051](./0051-only-the-caller-who-started-a-run-may-follow-its-progress.md), [0052](./0052-a-state-spawns-a-child-machine-as-it-invokes-a-train.md) | | `mediator` | [0001](./0001-architectural-rules-are-executable-guards.md), [0002](./0002-cross-repo-dependencies-are-exact-pinned.md), [0003](./0003-a-repo-depends-only-on-what-is-upstream.md), [0004](./0004-tests-assert-with-fluentassertions.md), [0005](./0005-a-skipped-test-is-a-runtime-decision.md), [0006](./0006-tests-synchronise-on-a-signal.md), [0007](./0007-the-canonical-train-name-is-the-interface-fullname.md), [0008](./0008-documentation-conventions-are-linted.md), [0010](./0010-the-public-api-surface-is-a-committed-baseline.md), [0011](./0011-test-frameworks-stay-out-of-shipped-libraries.md), [0012](./0012-an-adrs-exemplars-are-declared-by-attribute.md), [0013](./0013-trax-owns-the-vocabulary-for-its-own-concepts.md), [0014](./0014-a-test-owns-every-timeout-it-waits-behind.md), [0015](./0015-every-packageversion-names-a-referenced-package.md), [0016](./0016-a-junction-chain-is-a-declaration-not-a-step-of-the-work.md), [0017](./0017-a-callers-enqueue-goes-through-the-mediator.md), [0018](./0018-a-deferred-enqueue-is-staged-and-a-stranded-one-is-cancelled.md), [0019](./0019-queued-work-for-one-subject-runs-one-at-a-time.md), [0021](./0021-a-queue-hook-reads-its-input-through-traininput.md), [0023](./0023-caller-supplied-train-input-is-read-case-insensitively.md), [0026](./0026-train-roles-match-exactly-like-authorize.md), [0032](./0032-a-warning-fails-the-ci-build.md), [0033](./0033-a-package-validates-against-its-last-release.md), [0038](./0038-the-adr-guard-is-released-by-tag.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0040](./0040-a-chain-declares-every-track-and-a-decider-chooses-one.md), [0041](./0041-a-requeued-run-replays-the-decisions-of-the-run-it-repeats.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0043](./0043-the-site-is-published-from-a-release.md), [0044](./0044-property-tests-use-cscheck-in-test-projects-only.md), [0046](./0046-a-machine-state-invokes-a-train-and-only-that-entry-receives-its-outcome.md), [0047](./0047-a-checkpoint-stores-a-state-the-train-declares-and-a-resume-skips-to-it.md), [0048](./0048-a-machine-is-served-as-a-typed-api-and-the-server-applies-every-transition.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md), [0051](./0051-only-the-caller-who-started-a-run-may-follow-its-progress.md) | -| `samples` | [0001](./0001-architectural-rules-are-executable-guards.md), [0002](./0002-cross-repo-dependencies-are-exact-pinned.md), [0003](./0003-a-repo-depends-only-on-what-is-upstream.md), [0004](./0004-tests-assert-with-fluentassertions.md), [0005](./0005-a-skipped-test-is-a-runtime-decision.md), [0006](./0006-tests-synchronise-on-a-signal.md), [0007](./0007-the-canonical-train-name-is-the-interface-fullname.md), [0008](./0008-documentation-conventions-are-linted.md), [0011](./0011-test-frameworks-stay-out-of-shipped-libraries.md), [0012](./0012-an-adrs-exemplars-are-declared-by-attribute.md), [0013](./0013-trax-owns-the-vocabulary-for-its-own-concepts.md), [0014](./0014-a-test-owns-every-timeout-it-waits-behind.md), [0015](./0015-every-packageversion-names-a-referenced-package.md), [0016](./0016-a-junction-chain-is-a-declaration-not-a-step-of-the-work.md), [0032](./0032-a-warning-fails-the-ci-build.md), [0035](./0035-demo-credentials-exist-only-in-development.md), [0038](./0038-the-adr-guard-is-released-by-tag.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0040](./0040-a-chain-declares-every-track-and-a-decider-chooses-one.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0043](./0043-the-site-is-published-from-a-release.md), [0044](./0044-property-tests-use-cscheck-in-test-projects-only.md), [0045](./0045-a-parallel-step-runs-fixed-branches-on-copies-of-memory-and-the-join-commits.md), [0046](./0046-a-machine-state-invokes-a-train-and-only-that-entry-receives-its-outcome.md), [0047](./0047-a-checkpoint-stores-a-state-the-train-declares-and-a-resume-skips-to-it.md), [0048](./0048-a-machine-is-served-as-a-typed-api-and-the-server-applies-every-transition.md), [0049](./0049-browser-tests-use-playwright-for-dotnet-in-test-projects-only.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md), [0054](./0054-a-frontend-writes-every-document-that-reaches-a-train.md), [0055](./0055-the-react-console-is-the-operator-dashboard-and-the-blazor-one-is-removed.md) | +| `samples` | [0001](./0001-architectural-rules-are-executable-guards.md), [0002](./0002-cross-repo-dependencies-are-exact-pinned.md), [0003](./0003-a-repo-depends-only-on-what-is-upstream.md), [0004](./0004-tests-assert-with-fluentassertions.md), [0005](./0005-a-skipped-test-is-a-runtime-decision.md), [0006](./0006-tests-synchronise-on-a-signal.md), [0007](./0007-the-canonical-train-name-is-the-interface-fullname.md), [0008](./0008-documentation-conventions-are-linted.md), [0011](./0011-test-frameworks-stay-out-of-shipped-libraries.md), [0012](./0012-an-adrs-exemplars-are-declared-by-attribute.md), [0013](./0013-trax-owns-the-vocabulary-for-its-own-concepts.md), [0014](./0014-a-test-owns-every-timeout-it-waits-behind.md), [0015](./0015-every-packageversion-names-a-referenced-package.md), [0016](./0016-a-junction-chain-is-a-declaration-not-a-step-of-the-work.md), [0032](./0032-a-warning-fails-the-ci-build.md), [0035](./0035-demo-credentials-exist-only-in-development.md), [0038](./0038-the-adr-guard-is-released-by-tag.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0040](./0040-a-chain-declares-every-track-and-a-decider-chooses-one.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0043](./0043-the-site-is-published-from-a-release.md), [0044](./0044-property-tests-use-cscheck-in-test-projects-only.md), [0045](./0045-a-parallel-step-runs-fixed-branches-on-copies-of-memory-and-the-join-commits.md), [0046](./0046-a-machine-state-invokes-a-train-and-only-that-entry-receives-its-outcome.md), [0047](./0047-a-checkpoint-stores-a-state-the-train-declares-and-a-resume-skips-to-it.md), [0048](./0048-a-machine-is-served-as-a-typed-api-and-the-server-applies-every-transition.md), [0049](./0049-browser-tests-use-playwright-for-dotnet-in-test-projects-only.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md), [0054](./0054-a-frontend-writes-every-document-that-reaches-a-train.md), [0055](./0055-the-react-console-is-the-operator-dashboard-and-the-blazor-one-is-removed.md), [0056](./0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md) | | `scheduler` | [0001](./0001-architectural-rules-are-executable-guards.md), [0002](./0002-cross-repo-dependencies-are-exact-pinned.md), [0003](./0003-a-repo-depends-only-on-what-is-upstream.md), [0004](./0004-tests-assert-with-fluentassertions.md), [0005](./0005-a-skipped-test-is-a-runtime-decision.md), [0006](./0006-tests-synchronise-on-a-signal.md), [0007](./0007-the-canonical-train-name-is-the-interface-fullname.md), [0008](./0008-documentation-conventions-are-linted.md), [0009](./0009-feature-tables-ship-in-the-core-provider-set.md), [0010](./0010-the-public-api-surface-is-a-committed-baseline.md), [0011](./0011-test-frameworks-stay-out-of-shipped-libraries.md), [0012](./0012-an-adrs-exemplars-are-declared-by-attribute.md), [0013](./0013-trax-owns-the-vocabulary-for-its-own-concepts.md), [0014](./0014-a-test-owns-every-timeout-it-waits-behind.md), [0015](./0015-every-packageversion-names-a-referenced-package.md), [0016](./0016-a-junction-chain-is-a-declaration-not-a-step-of-the-work.md), [0017](./0017-a-callers-enqueue-goes-through-the-mediator.md), [0018](./0018-a-deferred-enqueue-is-staged-and-a-stranded-one-is-cancelled.md), [0019](./0019-queued-work-for-one-subject-runs-one-at-a-time.md), [0020](./0020-a-failure-is-classified-where-it-happens-and-carried.md), [0022](./0022-the-dashboard-and-the-api-share-one-operation-per-action.md), [0023](./0023-caller-supplied-train-input-is-read-case-insensitively.md), [0028](./0028-a-remote-runs-client-message-is-chosen-by-the-runner.md), [0032](./0032-a-warning-fails-the-ci-build.md), [0033](./0033-a-package-validates-against-its-last-release.md), [0036](./0036-a-feature-table-ships-with-its-model-in-effect.md), [0037](./0037-run-now-applies-the-same-per-record-checks-as-queueing.md), [0038](./0038-the-adr-guard-is-released-by-tag.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0040](./0040-a-chain-declares-every-track-and-a-decider-chooses-one.md), [0041](./0041-a-requeued-run-replays-the-decisions-of-the-run-it-repeats.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0043](./0043-the-site-is-published-from-a-release.md), [0044](./0044-property-tests-use-cscheck-in-test-projects-only.md), [0045](./0045-a-parallel-step-runs-fixed-branches-on-copies-of-memory-and-the-join-commits.md), [0046](./0046-a-machine-state-invokes-a-train-and-only-that-entry-receives-its-outcome.md), [0047](./0047-a-checkpoint-stores-a-state-the-train-declares-and-a-resume-skips-to-it.md), [0048](./0048-a-machine-is-served-as-a-typed-api-and-the-server-applies-every-transition.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md), [0051](./0051-only-the-caller-who-started-a-run-may-follow-its-progress.md), [0055](./0055-the-react-console-is-the-operator-dashboard-and-the-blazor-one-is-removed.md) | | `statemachine` | [0001](./0001-architectural-rules-are-executable-guards.md), [0002](./0002-cross-repo-dependencies-are-exact-pinned.md), [0003](./0003-a-repo-depends-only-on-what-is-upstream.md), [0004](./0004-tests-assert-with-fluentassertions.md), [0005](./0005-a-skipped-test-is-a-runtime-decision.md), [0006](./0006-tests-synchronise-on-a-signal.md), [0008](./0008-documentation-conventions-are-linted.md), [0010](./0010-the-public-api-surface-is-a-committed-baseline.md), [0011](./0011-test-frameworks-stay-out-of-shipped-libraries.md), [0012](./0012-an-adrs-exemplars-are-declared-by-attribute.md), [0013](./0013-trax-owns-the-vocabulary-for-its-own-concepts.md), [0014](./0014-a-test-owns-every-timeout-it-waits-behind.md), [0015](./0015-every-packageversion-names-a-referenced-package.md), [0032](./0032-a-warning-fails-the-ci-build.md), [0033](./0033-a-package-validates-against-its-last-release.md), [0038](./0038-the-adr-guard-is-released-by-tag.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0043](./0043-the-site-is-published-from-a-release.md), [0044](./0044-property-tests-use-cscheck-in-test-projects-only.md), [0046](./0046-a-machine-state-invokes-a-train-and-only-that-entry-receives-its-outcome.md), [0048](./0048-a-machine-is-served-as-a-typed-api-and-the-server-applies-every-transition.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md), [0052](./0052-a-state-spawns-a-child-machine-as-it-invokes-a-train.md) | -| `website` | [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0043](./0043-the-site-is-published-from-a-release.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md) | +| `website` | [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0043](./0043-the-site-is-published-from-a-release.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md), [0056](./0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md) | ## By area | Area | ADRs | | --- | --- | | `ci` | [0002](./0002-cross-repo-dependencies-are-exact-pinned.md), [0032](./0032-a-warning-fails-the-ci-build.md), [0033](./0033-a-package-validates-against-its-last-release.md), [0038](./0038-the-adr-guard-is-released-by-tag.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0043](./0043-the-site-is-published-from-a-release.md), [0049](./0049-browser-tests-use-playwright-for-dotnet-in-test-projects-only.md) | | `data-model` | [0018](./0018-a-deferred-enqueue-is-staged-and-a-stranded-one-is-cancelled.md), [0019](./0019-queued-work-for-one-subject-runs-one-at-a-time.md), [0020](./0020-a-failure-is-classified-where-it-happens-and-carried.md), [0036](./0036-a-feature-table-ships-with-its-model-in-effect.md), [0041](./0041-a-requeued-run-replays-the-decisions-of-the-run-it-repeats.md), [0046](./0046-a-machine-state-invokes-a-train-and-only-that-entry-receives-its-outcome.md), [0047](./0047-a-checkpoint-stores-a-state-the-train-declares-and-a-resume-skips-to-it.md), [0048](./0048-a-machine-is-served-as-a-typed-api-and-the-server-applies-every-transition.md), [0051](./0051-only-the-caller-who-started-a-run-may-follow-its-progress.md), [0052](./0052-a-state-spawns-a-child-machine-as-it-invokes-a-train.md) | -| `docs` | [0008](./0008-documentation-conventions-are-linted.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0043](./0043-the-site-is-published-from-a-release.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md) | +| `docs` | [0008](./0008-documentation-conventions-are-linted.md), [0039](./0039-a-docs-change-merges-after-its-code-and-deploys-after-its-release.md), [0043](./0043-the-site-is-published-from-a-release.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md), [0056](./0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md) | | `graphql` | [0017](./0017-a-callers-enqueue-goes-through-the-mediator.md), [0022](./0022-the-dashboard-and-the-api-share-one-operation-per-action.md), [0023](./0023-caller-supplied-train-input-is-read-case-insensitively.md), [0026](./0026-train-roles-match-exactly-like-authorize.md), [0028](./0028-a-remote-runs-client-message-is-chosen-by-the-runner.md), [0037](./0037-run-now-applies-the-same-per-record-checks-as-queueing.md), [0041](./0041-a-requeued-run-replays-the-decisions-of-the-run-it-repeats.md), [0046](./0046-a-machine-state-invokes-a-train-and-only-that-entry-receives-its-outcome.md), [0047](./0047-a-checkpoint-stores-a-state-the-train-declares-and-a-resume-skips-to-it.md), [0048](./0048-a-machine-is-served-as-a-typed-api-and-the-server-applies-every-transition.md), [0051](./0051-only-the-caller-who-started-a-run-may-follow-its-progress.md), [0053](./0053-a-client-document-is-registered-by-the-host-at-boot.md), [0054](./0054-a-frontend-writes-every-document-that-reaches-a-train.md) | | `migrations` | [0009](./0009-feature-tables-ship-in-the-core-provider-set.md), [0036](./0036-a-feature-table-ships-with-its-model-in-effect.md) | | `naming` | [0007](./0007-the-canonical-train-name-is-the-interface-fullname.md), [0013](./0013-trax-owns-the-vocabulary-for-its-own-concepts.md) | -| `packaging` | [0002](./0002-cross-repo-dependencies-are-exact-pinned.md), [0003](./0003-a-repo-depends-only-on-what-is-upstream.md), [0010](./0010-the-public-api-surface-is-a-committed-baseline.md), [0011](./0011-test-frameworks-stay-out-of-shipped-libraries.md), [0015](./0015-every-packageversion-names-a-referenced-package.md), [0033](./0033-a-package-validates-against-its-last-release.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0044](./0044-property-tests-use-cscheck-in-test-projects-only.md), [0049](./0049-browser-tests-use-playwright-for-dotnet-in-test-projects-only.md) | +| `packaging` | [0002](./0002-cross-repo-dependencies-are-exact-pinned.md), [0003](./0003-a-repo-depends-only-on-what-is-upstream.md), [0010](./0010-the-public-api-surface-is-a-committed-baseline.md), [0011](./0011-test-frameworks-stay-out-of-shipped-libraries.md), [0015](./0015-every-packageversion-names-a-referenced-package.md), [0033](./0033-a-package-validates-against-its-last-release.md), [0042](./0042-trax-is-one-repository-and-releases-at-one-version.md), [0044](./0044-property-tests-use-cscheck-in-test-projects-only.md), [0049](./0049-browser-tests-use-playwright-for-dotnet-in-test-projects-only.md), [0056](./0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md) | | `platform` | [0001](./0001-architectural-rules-are-executable-guards.md), [0003](./0003-a-repo-depends-only-on-what-is-upstream.md), [0007](./0007-the-canonical-train-name-is-the-interface-fullname.md), [0012](./0012-an-adrs-exemplars-are-declared-by-attribute.md), [0013](./0013-trax-owns-the-vocabulary-for-its-own-concepts.md), [0016](./0016-a-junction-chain-is-a-declaration-not-a-step-of-the-work.md), [0017](./0017-a-callers-enqueue-goes-through-the-mediator.md), [0018](./0018-a-deferred-enqueue-is-staged-and-a-stranded-one-is-cancelled.md), [0019](./0019-queued-work-for-one-subject-runs-one-at-a-time.md), [0020](./0020-a-failure-is-classified-where-it-happens-and-carried.md), [0021](./0021-a-queue-hook-reads-its-input-through-traininput.md), [0022](./0022-the-dashboard-and-the-api-share-one-operation-per-action.md), [0023](./0023-caller-supplied-train-input-is-read-case-insensitively.md), [0026](./0026-train-roles-match-exactly-like-authorize.md), [0028](./0028-a-remote-runs-client-message-is-chosen-by-the-runner.md), [0035](./0035-demo-credentials-exist-only-in-development.md), [0037](./0037-run-now-applies-the-same-per-record-checks-as-queueing.md), [0040](./0040-a-chain-declares-every-track-and-a-decider-chooses-one.md), [0041](./0041-a-requeued-run-replays-the-decisions-of-the-run-it-repeats.md), [0045](./0045-a-parallel-step-runs-fixed-branches-on-copies-of-memory-and-the-join-commits.md), [0046](./0046-a-machine-state-invokes-a-train-and-only-that-entry-receives-its-outcome.md), [0047](./0047-a-checkpoint-stores-a-state-the-train-declares-and-a-resume-skips-to-it.md), [0048](./0048-a-machine-is-served-as-a-typed-api-and-the-server-applies-every-transition.md), [0052](./0052-a-state-spawns-a-child-machine-as-it-invokes-a-train.md), [0055](./0055-the-react-console-is-the-operator-dashboard-and-the-blazor-one-is-removed.md) | | `providers` | [0009](./0009-feature-tables-ship-in-the-core-provider-set.md), [0036](./0036-a-feature-table-ships-with-its-model-in-effect.md) | | `testing` | [0001](./0001-architectural-rules-are-executable-guards.md), [0004](./0004-tests-assert-with-fluentassertions.md), [0005](./0005-a-skipped-test-is-a-runtime-decision.md), [0006](./0006-tests-synchronise-on-a-signal.md), [0008](./0008-documentation-conventions-are-linted.md), [0010](./0010-the-public-api-surface-is-a-committed-baseline.md), [0011](./0011-test-frameworks-stay-out-of-shipped-libraries.md), [0012](./0012-an-adrs-exemplars-are-declared-by-attribute.md), [0014](./0014-a-test-owns-every-timeout-it-waits-behind.md), [0015](./0015-every-packageversion-names-a-referenced-package.md), [0016](./0016-a-junction-chain-is-a-declaration-not-a-step-of-the-work.md), [0044](./0044-property-tests-use-cscheck-in-test-projects-only.md), [0049](./0049-browser-tests-use-playwright-for-dotnet-in-test-projects-only.md), [0050](./0050-docs-pages-are-typed-and-their-code-is-checked-against-the-code.md) | @@ -124,3 +124,4 @@ realising. A repo-scoped ADR omits the key, because its path already says it. | [0053](./0053-a-client-document-is-registered-by-the-host-at-boot.md) | A client document is registered by the host at boot | api, cli | graphql | | [0054](./0054-a-frontend-writes-every-document-that-reaches-a-train.md) | A frontend writes every document that reaches a train | api, cli, samples | graphql | | [0055](./0055-the-react-console-is-the-operator-dashboard-and-the-blazor-one-is-removed.md) | The React console is the operator dashboard, and the Blazor one is removed | api, dashboard, scheduler, samples, docs | platform | +| [0056](./0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md) | Agent skills are written in the docs and published from a release | docs, samples, website | docs, packaging | diff --git a/Trax.Docs/contributing/writing-docs.md b/Trax.Docs/contributing/writing-docs.md index 95a497d70..c33286e51 100644 --- a/Trax.Docs/contributing/writing-docs.md +++ b/Trax.Docs/contributing/writing-docs.md @@ -18,8 +18,8 @@ to the code. ## Front matter Every published page opens with a YAML front matter block. Published means everything under -`Trax.Docs` except `README.md` files and the `adr/`, `tests/`, `tools/`, `.claude/` and `.github/` -folders, which is what `Trax.Website/scripts/sync-docs.sh` copies. +`Trax.Docs` except `README.md` files and the `adr/`, `skills/`, `tests/`, `tools/`, `.claude/` and +`.github/` folders, which is what `Trax.Website/scripts/sync-docs.sh` copies. ```yaml --- @@ -384,6 +384,26 @@ means updating its line. Never add a line to one of them: fix the page instead. +## Agent skills + +`skills//SKILL.md` holds a skill for coding agents working in an app built on Trax. It is not +a page: after each release, an approved run of the `Skills` workflow publishes it to the +[`skills` branch](https://github.com/TraxSharp/Trax/tree/skills), stamped with the release's version, and agents +install it from there (`Trax.Docs/adr/0056`). A skill says +what to decide first, which pages to read and which mistakes are known, and links to the page for +the detail rather than repeating it. + +- **Frontmatter** is `name` (the folder's name), `description` (when to use it: an agent matches + its task against this) and `metadata` holding `internal: true`. The marker keeps + `npx skills add TraxSharp/Trax` from offering unreleased skills; the release removes it. +- **Links to the docs are full URLs**, `https://traxsharp.net/docs/...`, since the agent reading + one is in another repository. Other files the skill needs sit in its folder and are linked + relatively. Markdown only: a skill never ships a script. +- **C# fences carry the same markers as a page**, and `compile` fences are compiled the same way. + +`SkillsAreWellFormedTests` checks all of it, and `Trax.Skills.Publish` refuses to stage a tree that +breaks it. + ## Voice Write like a senior engineer talking to other engineers. diff --git a/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Infrastructure/Fences.cs b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Infrastructure/Fences.cs index 766571b72..fd5b63739 100644 --- a/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Infrastructure/Fences.cs +++ b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Infrastructure/Fences.cs @@ -199,6 +199,13 @@ public static IEnumerable InDocs() => public static IEnumerable OnPublishedPages() => InDocs().Where(f => RepoRoot.IsPublished(f.Page)); + /// + /// Every fence a reader copies from: the published pages and the agent skills, which a release + /// publishes on their own (Trax.Docs/adr/0056) and which carry the same markers. + /// + public static IEnumerable OnPagesAndSkills() => + InDocs().Where(f => RepoRoot.IsPublished(f.Page) || RepoRoot.IsSkill(f.Page)); + private static List GroupsOf(IEnumerable words, int openLine) { var groups = new List(); diff --git a/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Infrastructure/RepoRoot.cs b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Infrastructure/RepoRoot.cs index 3c4dd00ce..793830f9f 100644 --- a/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Infrastructure/RepoRoot.cs +++ b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Infrastructure/RepoRoot.cs @@ -42,10 +42,15 @@ public static bool IsPublished(string relative) => System.IO.Path.GetFileName(relative) != "README.md" && !relative.StartsWith("adr/", StringComparison.Ordinal) && !relative.StartsWith(".claude/", StringComparison.Ordinal) + && !relative.StartsWith("skills/", StringComparison.Ordinal) && !relative.StartsWith("tools/", StringComparison.Ordinal) && !relative.StartsWith("tests/", StringComparison.Ordinal) && !relative.StartsWith(".github/", StringComparison.Ordinal); + /// Whether the file at is part of an agent skill. + public static bool IsSkill(string relative) => + relative.StartsWith("skills/", StringComparison.Ordinal); + private static bool IsExcluded(string path) { var s = System.IO.Path.DirectorySeparatorChar; diff --git a/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Tests/EveryCSharpFenceIsMarkedTests.cs b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Tests/EveryCSharpFenceIsMarkedTests.cs index ca3e1bae2..4af0d3bb1 100644 --- a/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Tests/EveryCSharpFenceIsMarkedTests.cs +++ b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Tests/EveryCSharpFenceIsMarkedTests.cs @@ -1,7 +1,7 @@ namespace Trax.Docs.Snippets.Tests.Tests; /// -/// Every C# fence on a published page says how it relates to the code: compile (it +/// Every C# fence on a published page or in an agent skill says how it relates to the code: compile (it /// builds), from=path[#region] (it is an excerpt of that file) or fragment (it is /// partial on purpose). Exactly one of them, and never compile on a signature. /// @@ -28,10 +28,10 @@ public class EveryCSharpFenceIsMarkedTests "tests/Trax.Docs.Snippets.Tests/KnownUnmarkedFencePages.txt"; [Test] - public void Every_CSharpFence_OnAPublishedPage_IsMarked() + public void Every_CSharpFence_OnAPageOrSkill_IsMarked() { var unmarked = Fences - .OnPublishedPages() + .OnPagesAndSkills() .Where(f => f.IsCSharp && f.Markers.Count == 0) .ToList(); var pages = unmarked.Select(f => f.Page).ToHashSet(StringComparer.Ordinal); @@ -54,7 +54,7 @@ public void Every_CSharpFence_OnAPublishedPage_IsMarked() offenders .Should() .BeEmpty( - "every C# fence on a published page carries `compile` (it builds in CI), " + "every C# fence on a published page or in a skill carries `compile` (it builds in CI), " + "`from=[#region]` (it is an excerpt of that file, checked against it) or " + "`fragment` (deliberately partial: a signature, a before and after, a line out " + $"of context). See {Adr} and /docs/contributing/writing-docs. Do not add the " @@ -89,7 +89,7 @@ public void KnownUnmarkedFencePages_IsSortedAndUnique() public void Fence_CarriesAtMostOneMarker() { var offenders = Fences - .OnPublishedPages() + .OnPagesAndSkills() .Where(f => f.Markers.Count > 1) .Select(f => $"{f.Location} ({string.Join(", ", f.Markers)})") .ToList(); @@ -109,7 +109,7 @@ public void Fence_CarriesAtMostOneMarker() public void SignatureFence_IsNotCompiled() { var offenders = Fences - .OnPublishedPages() + .OnPagesAndSkills() .Where(f => f.IsCompiled && f.IsUnderSignature) .Select(f => $"{f.Location} (under ## {f.Section})") .ToList(); diff --git a/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Tests/SampleExcerptsMatchSourceTests.cs b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Tests/SampleExcerptsMatchSourceTests.cs index 57e36bb9e..59cbc14c0 100644 --- a/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Tests/SampleExcerptsMatchSourceTests.cs +++ b/Trax.Docs/tests/Trax.Docs.Snippets.Tests/Tests/SampleExcerptsMatchSourceTests.cs @@ -26,7 +26,7 @@ public class SampleExcerptsMatchSourceTests public void Every_FromFence_IsAnExcerptOfItsFile() { var offenders = Fences - .OnPublishedPages() + .OnPagesAndSkills() .Where(f => f.From is not null) .Select(Excerpts.Problem) .OfType() diff --git a/Trax.Docs/tests/Trax.Docs.Tests/Infrastructure/RepoRoot.cs b/Trax.Docs/tests/Trax.Docs.Tests/Infrastructure/RepoRoot.cs index 121a3290a..e953a8aef 100644 --- a/Trax.Docs/tests/Trax.Docs.Tests/Infrastructure/RepoRoot.cs +++ b/Trax.Docs/tests/Trax.Docs.Tests/Infrastructure/RepoRoot.cs @@ -40,6 +40,7 @@ public static bool IsPublished(string relative) => System.IO.Path.GetFileName(relative) != "README.md" && !relative.StartsWith("adr/", StringComparison.Ordinal) && !relative.StartsWith(".claude/", StringComparison.Ordinal) + && !relative.StartsWith("skills/", StringComparison.Ordinal) && !relative.StartsWith("tools/", StringComparison.Ordinal) && !relative.StartsWith("tests/", StringComparison.Ordinal) && !relative.StartsWith(".github/", StringComparison.Ordinal); diff --git a/Trax.Docs/tests/Trax.Docs.Tests/Tests/SdkReferenceBlockTests.cs b/Trax.Docs/tests/Trax.Docs.Tests/Tests/SdkReferenceBlockTests.cs index e3ec5dcfa..587c26ce1 100644 --- a/Trax.Docs/tests/Trax.Docs.Tests/Tests/SdkReferenceBlockTests.cs +++ b/Trax.Docs/tests/Trax.Docs.Tests/Tests/SdkReferenceBlockTests.cs @@ -34,6 +34,9 @@ public class SdkReferenceBlockTests // is illustrative, and the format spec's code is the ADR template itself. "adr/", ".claude/", + // Agent skills are published to the skills branch, not the site, and link to the + // pages they route to inline (Trax.Docs/adr/0056). + "skills/", }; private static readonly HashSet ExemptFiles = new(StringComparer.Ordinal) diff --git a/Trax.Docs/tests/Trax.Docs.Tests/Tests/SkillsAreWellFormedTests.cs b/Trax.Docs/tests/Trax.Docs.Tests/Tests/SkillsAreWellFormedTests.cs new file mode 100644 index 000000000..d1c2b8ead --- /dev/null +++ b/Trax.Docs/tests/Trax.Docs.Tests/Tests/SkillsAreWellFormedTests.cs @@ -0,0 +1,139 @@ +using Trax.Skills.Publish; + +namespace Trax.Docs.Tests.Tests; + +/// +/// The agent skills under skills/ are ready for a release to publish: each passes +/// , every docs page a skill sends an agent to exists, and the marketplace +/// at the repository root points Claude Code at the branch the Skills workflow writes. +/// +/// A skill is read by an agent in someone else's repository, so a link is only useful as +/// a full https://traxsharp.net/docs/... URL, and a link to a page that moved sends the +/// agent to a 404 at the moment it asked for the detail. The skill's own C# fences carry the +/// docs markers and are compiled by CompiledSnippetsTests.cs in Trax.Docs.Snippets.Tests, +/// like any page's. +/// +/// Enforces Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md. +/// +[Property( + "adr", + "Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md" +)] +[TestFixture] +public class SkillsAreWellFormedTests +{ + private const string Adr = + "Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md"; + + private const string Site = "https://traxsharp.net"; + + private static string SkillsRoot => RepoRoot.Combine("skills"); + + [Test] + public void EverySkill_PassesTheReleaseRules() + { + var problems = SkillRules.Check(SkillsRoot).Select(p => p.ToString()).ToList(); + + problems + .Should() + .BeEmpty( + "the Skills workflow stages these skills with the same rules and stops on the first broken " + + "one, so a skill that breaks them blocks publishing. See " + + Adr + + ". Problems:\n " + + string.Join("\n ", problems) + ); + } + + [Test] + public void EveryDocsLink_InASkill_ResolvesToAPublishedPage() + { + var site = DocsSite.Load(); + var offenders = new List(); + + foreach (var file in SkillFiles()) + foreach (var target in SkillRules.Links(File.ReadAllText(file))) + { + var problem = Check(target, site); + if (problem is not null) + offenders.Add( + $"{RepoRoot.Relative(file).Replace('\\', '/')} -> {target} ({problem})" + ); + } + + offenders + .Should() + .BeEmpty( + "a skill is read in another repository, so a docs link is a full " + + $"{Site}/docs/... URL to a page this checkout publishes, with an anchor that " + + "matches a heading on it. See " + + Adr + + ". Offenders:\n " + + string.Join("\n ", offenders) + ); + } + + [Test] + public void TheRootMarketplace_IsTheOneTheReleaseExpects() + { + var path = Path.Combine(RepoRoot.Monorepo, ".claude-plugin", "marketplace.json"); + + File.Exists(path) + .Should() + .BeTrue($"`/plugin marketplace add TraxSharp/Trax` reads {path}. See {Adr}."); + File.ReadAllText(path) + .Replace("\r\n", "\n") + .Should() + .Be( + Stager.MarketplaceJson(), + "the marketplace on main names the plugin, and the plugin's source is the " + + $"`{Stager.Branch}` branch the Skills workflow writes. Regenerate it with " + + "`dotnet run --project Trax.Docs/tools/Trax.Skills.Publish -- marketplace`. See " + + Adr + ); + } + + [TestCase("/docs/getting-started", "site-relative")] + [TestCase("https://traxsharp.net/docs/no-such-page", "no published page")] + [TestCase("https://traxsharp.net/docs/getting-started#no-such-heading", "no such heading")] + [TestCase("http://traxsharp.net/docs/getting-started", "https")] + public void Check_RefusesWhatAnAgentCannotFollow(string target, string problem) => + Check(target, DocsSite.Load()).Should().Contain(problem); + + [TestCase("https://traxsharp.net/docs/getting-started")] + [TestCase("https://traxsharp.net/roadmap")] + [TestCase("https://github.com/TraxSharp/Trax")] + [TestCase("more.md")] + [TestCase("#steps")] + public void Check_AcceptsWhatAnAgentCanFollow(string target) => + Check(target, DocsSite.Load()).Should().BeNull(); + + private static IEnumerable SkillFiles() => + Directory.Exists(SkillsRoot) + ? Directory + .EnumerateFiles(SkillsRoot, "*.md", SearchOption.AllDirectories) + .Order(StringComparer.Ordinal) + : []; + + private static string? Check(string target, DocsSite site) + { + if (target.StartsWith('/')) + return "site-relative; an agent outside traxsharp.net cannot follow it, write " + + Site + + target; + if (target.StartsWith("http://traxsharp.net", StringComparison.Ordinal)) + return "use https"; + if (!target.StartsWith(Site + "/docs", StringComparison.Ordinal)) + return null; + + var path = target[Site.Length..]; + var hash = path.IndexOf('#'); + var anchor = hash < 0 ? null : path[(hash + 1)..]; + var file = site.Resolve(hash < 0 ? path : path[..hash]); + if (file is null) + return "no published page at this path"; + if (anchor is not null && !site.HeadingIds(file).Contains(Uri.UnescapeDataString(anchor))) + return $"no such heading on {file}"; + return null; + } +} diff --git a/Trax.Docs/tests/Trax.Docs.Tests/Trax.Docs.Tests.csproj b/Trax.Docs/tests/Trax.Docs.Tests/Trax.Docs.Tests.csproj index fc424cf07..fff345707 100644 --- a/Trax.Docs/tests/Trax.Docs.Tests/Trax.Docs.Tests.csproj +++ b/Trax.Docs/tests/Trax.Docs.Tests/Trax.Docs.Tests.csproj @@ -18,4 +18,8 @@ + + + + diff --git a/Trax.Docs/tests/Trax.Docs.Tests/packages.lock.json b/Trax.Docs/tests/Trax.Docs.Tests/packages.lock.json index 550fe1a89..9e395d789 100644 --- a/Trax.Docs/tests/Trax.Docs.Tests/packages.lock.json +++ b/Trax.Docs/tests/Trax.Docs.Tests/packages.lock.json @@ -117,6 +117,9 @@ "dependencies": { "Microsoft.TestPlatform.ObjectModel": "18.10.1" } + }, + "trax.skills.publish": { + "type": "Project" } } } diff --git a/Trax.Docs/tests/Trax.Skills.Publish.Tests/GlobalUsings.cs b/Trax.Docs/tests/Trax.Skills.Publish.Tests/GlobalUsings.cs new file mode 100644 index 000000000..05fd20486 --- /dev/null +++ b/Trax.Docs/tests/Trax.Skills.Publish.Tests/GlobalUsings.cs @@ -0,0 +1,7 @@ +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using AwesomeAssertions; +global using NUnit.Framework; +global using Trax.Skills.Publish; diff --git a/Trax.Docs/tests/Trax.Skills.Publish.Tests/TempSkills.cs b/Trax.Docs/tests/Trax.Skills.Publish.Tests/TempSkills.cs new file mode 100644 index 000000000..c511ab171 --- /dev/null +++ b/Trax.Docs/tests/Trax.Skills.Publish.Tests/TempSkills.cs @@ -0,0 +1,56 @@ +namespace Trax.Skills.Publish.Tests; + +/// A throwaway skills/ folder, deleted on dispose. +internal sealed class TempSkills : IDisposable +{ + public const string Internal = "metadata:\n internal: true\n"; + + public TempSkills() + { + Root = Path.Combine(Path.GetTempPath(), "trax-skills-" + Guid.NewGuid().ToString("N")); + Skills = Path.Combine(Root, "skills"); + Directory.CreateDirectory(Skills); + License = Path.Combine(Root, "LICENSE"); + File.WriteAllText(License, "MIT License\n"); + } + + public string Root { get; } + + public string Skills { get; } + + public string License { get; } + + public string Out => Path.Combine(Root, "out"); + + /// A skill that passes every rule, unless the caller overrides part of it. + public TempSkills Skill( + string folder, + string? name = null, + string description = "Use when writing a train in an app built on Trax.", + string metadata = Internal, + string body = "# Heading\n\nText.\n" + ) + { + var dir = Path.Combine(Skills, folder); + Directory.CreateDirectory(dir); + File.WriteAllText( + Path.Combine(dir, "SKILL.md"), + $"---\nname: {name ?? folder}\ndescription: {description}\n{metadata}---\n{body}" + ); + return this; + } + + public TempSkills File_(string relative, string text = "x") + { + var path = Path.Combine(Skills, relative); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, text); + return this; + } + + public void Dispose() + { + if (Directory.Exists(Root)) + Directory.Delete(Root, recursive: true); + } +} diff --git a/Trax.Docs/tests/Trax.Skills.Publish.Tests/Tests/SkillRulesTests.cs b/Trax.Docs/tests/Trax.Skills.Publish.Tests/Tests/SkillRulesTests.cs new file mode 100644 index 000000000..390edb520 --- /dev/null +++ b/Trax.Docs/tests/Trax.Skills.Publish.Tests/Tests/SkillRulesTests.cs @@ -0,0 +1,144 @@ +namespace Trax.Skills.Publish.Tests.Tests; + +[TestFixture] +public class SkillRulesTests +{ + private static IReadOnlyList Problems(TempSkills skills) => + [.. SkillRules.Check(skills.Skills).Select(p => p.ToString())]; + + [Test] + public void AWellFormedSkill_HasNoProblems() + { + using var skills = new TempSkills() + .Skill("trax-write-a-train", body: "See [the layout](layout.md#top).\n") + .File_("trax-write-a-train/layout.md"); + + Problems(skills).Should().BeEmpty(); + } + + [Test] + public void ASkillWithoutTheInternalMarker_IsRefused() + { + // Without it, `npx skills add TraxSharp/Trax` serves the skill from main: the CLI's + // recursive fallback finds Trax.Docs/skills/ (skills 1.7.4, 2026-10-20). + using var skills = new TempSkills().Skill("trax", metadata: ""); + + Problems(skills).Should().ContainSingle().Which.Should().Contain("internal: true"); + } + + [Test] + public void AStampedVersionInTheSource_IsRefused() + { + using var skills = new TempSkills().Skill( + "trax", + metadata: "metadata:\n internal: true\n trax-version: \"1.0.0\"\n" + ); + + Problems(skills).Should().ContainSingle().Which.Should().Contain("the release writes it"); + } + + [Test] + public void ANameThatIsNotTheFolder_IsRefused() + { + using var skills = new TempSkills().Skill("trax", name: "trax-core"); + + Problems(skills) + .Should() + .ContainSingle() + .Which.Should() + .Contain("but the folder is 'trax'"); + } + + [TestCase("Trax")] + [TestCase("trax_core")] + [TestCase("trax--core")] + public void ANameOutsideTheSpec_IsRefused(string folder) + { + using var skills = new TempSkills().Skill(folder); + + Problems(skills).Should().ContainSingle().Which.Should().Contain("lowercase words"); + } + + [Test] + public void AMissingOrOverlongDescription_IsRefused() + { + using var skills = new TempSkills() + .Skill("a", description: "\"\"") + .Skill("b", description: new string('x', SkillRules.MaxDescriptionLength + 1)) + .Skill("c", description: "Use for work."); + + Problems(skills) + .Should() + .SatisfyRespectively( + a => a.Should().StartWith("a: ").And.Contain("no 'description'"), + b => b.Should().StartWith("b: ").And.Contain("the limit is"), + c => c.Should().StartWith("c: ").And.Contain("'<' or '>'") + ); + } + + [Test] + public void AnUnknownKey_IsRefused() + { + using var skills = new TempSkills().Skill( + "trax", + metadata: "allowed-tools: Bash\nmetadata:\n internal: true\n owner: me\n" + ); + + Problems(skills) + .Should() + .SatisfyRespectively( + a => a.Should().Contain("key 'allowed-tools'"), + b => b.Should().Contain("metadata key 'owner'") + ); + } + + [Test] + public void AScriptInASkill_IsRefused() + { + using var skills = new TempSkills().Skill("trax").File_("trax/scripts/setup.sh"); + + Problems(skills).Should().ContainSingle().Which.Should().Contain("never a script"); + } + + [Test] + public void AFileOutsideAnySkill_IsRefused() + { + using var skills = new TempSkills().Skill("trax").File_("notes.md"); + + Problems(skills).Should().ContainSingle().Which.Should().StartWith("notes.md: "); + } + + [Test] + public void ALinkThatIsMissingOrLeavesTheSkill_IsRefused() + { + using var skills = new TempSkills() + .Skill( + "trax", + body: "[a](missing.md) [b](../other/SKILL.md) [c](https://traxsharp.net/docs) [d](#x)\n" + ) + .Skill("other"); + + Problems(skills) + .Should() + .SatisfyRespectively( + a => a.Should().Contain("'missing.md' names a file that does not exist"), + b => b.Should().Contain("leaves the skill's folder") + ); + } + + [Test] + public void ALinkInsideAFence_IsNotALink() + { + using var skills = new TempSkills().Skill("trax", body: "```md\n[a](missing.md)\n```\n"); + + Problems(skills).Should().BeEmpty(); + } + + [Test] + public void BrokenFrontmatter_IsReportedAndStopsTheOtherRules() + { + using var skills = new TempSkills().Skill("trax", metadata: " stray: indent\n"); + + Problems(skills).Should().ContainSingle().Which.Should().Contain("frontmatter:"); + } +} diff --git a/Trax.Docs/tests/Trax.Skills.Publish.Tests/Tests/StagerTests.cs b/Trax.Docs/tests/Trax.Skills.Publish.Tests/Tests/StagerTests.cs new file mode 100644 index 000000000..b20521a3f --- /dev/null +++ b/Trax.Docs/tests/Trax.Skills.Publish.Tests/Tests/StagerTests.cs @@ -0,0 +1,108 @@ +using System.Text.Json.Nodes; + +namespace Trax.Skills.Publish.Tests.Tests; + +[TestFixture] +public class StagerTests +{ + [Test] + public void Stage_StampsTheVersion_AndDropsTheInternalMarker() + { + using var skills = new TempSkills() + .Skill("trax", body: "# Trax\n\nRead [more](more.md).\n") + .File_("trax/more.md", "more"); + + Stager.Stage(skills.Skills, "1.70.0", skills.License, skills.Out).Should().BeEmpty(); + + var staged = File.ReadAllText(Path.Combine(skills.Out, "skills", "trax", "SKILL.md")); + staged + .Should() + .Be( + "---\nname: trax\ndescription: Use when writing a train in an app built on Trax.\n" + + "metadata:\n trax-version: \"1.70.0\"\n---\n# Trax\n\nRead [more](more.md).\n" + ); + File.ReadAllText(Path.Combine(skills.Out, "skills", "trax", "more.md")).Should().Be("more"); + File.Exists(Path.Combine(skills.Out, "LICENSE")).Should().BeTrue(); + } + + [Test] + public void Stage_WritesThePluginAtTheReleaseVersion_AndNoMarketplace() + { + using var skills = new TempSkills().Skill("trax").Skill("trax-upgrade"); + + Stager.Stage(skills.Skills, "1.70.0", skills.License, skills.Out).Should().BeEmpty(); + + var plugin = JsonNode.Parse( + File.ReadAllText(Path.Combine(skills.Out, ".claude-plugin", "plugin.json")) + )!; + plugin["name"]!.GetValue().Should().Be("trax"); + plugin["version"]!.GetValue().Should().Be("1.70.0"); + File.Exists(Path.Combine(skills.Out, ".claude-plugin", "marketplace.json")) + .Should() + .BeFalse("the marketplace lives on main and points at the branch"); + + File.ReadAllText(Path.Combine(skills.Out, "README.md")) + .Should() + .Contain("- `trax`\n- `trax-upgrade`") + .And.Contain("tree/skills-v1.70.0") + .And.Contain("/plugin install trax@traxsharp"); + } + + [Test] + public void TheMarketplace_PointsTheTraxPluginAtTheSkillsBranch() + { + var market = Stager.MarketplaceManifest(); + + market["name"]!.GetValue().Should().Be("traxsharp"); + var source = market["plugins"]![0]!["source"]!; + source["source"]!.GetValue().Should().Be("github"); + source["repo"]!.GetValue().Should().Be("TraxSharp/Trax"); + source["ref"]!.GetValue().Should().Be("skills"); + } + + [Test] + public void Stage_RefusesASkillThatBreaksARule_AndWritesNothing() + { + using var skills = new TempSkills().Skill("trax", metadata: ""); + + Stager.Stage(skills.Skills, "1.70.0", skills.License, skills.Out).Should().ContainSingle(); + Directory.Exists(skills.Out).Should().BeFalse(); + } + + [TestCase("v1.70.0")] + [TestCase("1.70")] + [TestCase("main")] + public void Stage_RefusesAVersionThatIsNotARelease(string version) + { + using var skills = new TempSkills().Skill("trax"); + + Stager + .Stage(skills.Skills, version, skills.License, skills.Out) + .Should() + .ContainSingle() + .Which.Message.Should() + .Contain("not a release version"); + } + + [Test] + public void Stage_RefusesNoSkills_AndANonEmptyOutput() + { + using var skills = new TempSkills(); + Stager + .Stage(skills.Skills, "1.70.0", skills.License, skills.Out) + .Should() + .ContainSingle() + .Which.Message.Should() + .Contain("no skills"); + + skills.Skill("trax"); + Directory.CreateDirectory(skills.Out); + File.WriteAllText(Path.Combine(skills.Out, "stale"), ""); + Stager + .Stage(skills.Skills, "1.70.0", skills.License, skills.Out) + .Should() + .ContainSingle() + .Which.Message.Should() + .Contain("not empty"); + } +} diff --git a/Trax.Docs/tests/Trax.Skills.Publish.Tests/Trax.Skills.Publish.Tests.csproj b/Trax.Docs/tests/Trax.Skills.Publish.Tests/Trax.Skills.Publish.Tests.csproj new file mode 100644 index 000000000..fff345707 --- /dev/null +++ b/Trax.Docs/tests/Trax.Skills.Publish.Tests/Trax.Skills.Publish.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + diff --git a/Trax.Docs/tests/Trax.Skills.Publish.Tests/packages.lock.json b/Trax.Docs/tests/Trax.Skills.Publish.Tests/packages.lock.json new file mode 100644 index 000000000..9e395d789 --- /dev/null +++ b/Trax.Docs/tests/Trax.Skills.Publish.Tests/packages.lock.json @@ -0,0 +1,126 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "AwesomeAssertions": { + "type": "Direct", + "requested": "[9.6.0, )", + "resolved": "9.6.0", + "contentHash": "8kdtgI+tZIgP46ncnk650/dU4JSrn+n2Dkic8FvjIFn72P6Nv1Afyr5F8F3PsokHg1GBSBVo1/Z+eZOrU5esSA==" + }, + "coverlet.collector": { + "type": "Direct", + "requested": "[10.1.0, )", + "resolved": "10.1.0", + "contentHash": "SlYCIHnBYxINSUpR75SiM5JEmnx11pqT+Ac/C/fUqTTA0dLPl9eYzmFXKKuxmQhL3ZdTQl4PBD3YdAxAauaKLQ==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.10.1, )", + "resolved": "18.10.1", + "contentHash": "SxCFvJE/2ltUQgCgv6+Ixagohw4sZc9hM7Wid2foaNM7paBscktoHLoRDz91fW8wbHG87Rg9Ku59/8PXumqUrA==", + "dependencies": { + "Microsoft.CodeCoverage": "18.10.1", + "Microsoft.TestPlatform.TestHost": "18.10.1" + } + }, + "Microsoft.Testing.Extensions.VSTestBridge": { + "type": "Direct", + "requested": "[2.5.1, )", + "resolved": "2.5.1", + "contentHash": "Lhz4J5qYYfPaQxtM7cCn//yGst2RMZDoCDv2rd7hL7N92PzwubMWZTQ3Z1U1IqulRgZldXqC60uExuO7GnusFQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.10.1", + "Microsoft.Testing.Extensions.Telemetry": "2.5.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.5.1", + "Microsoft.Testing.Platform": "[2.5.1, 3.0.0)" + } + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Direct", + "requested": "[2.5.1, )", + "resolved": "2.5.1", + "contentHash": "lYawcAwY+h76rQmpma2n0HaL8zkOFbbKapso0Eb+uOAUQn7333pIByLftEuiO67csAoZufHNMwi6PqhNemWQXA==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.5.1, 3.0.0)" + } + }, + "NUnit": { + "type": "Direct", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "tW5Juazgh4CI4pXo5c9c2J9J4i9vwZMm/FJ+gRRe0plJHLCz7Ck/raPuE5q1xWmLAUwpao4Ht1Uy1uRN3Wo5sw==" + }, + "NUnit.Analyzers": { + "type": "Direct", + "requested": "[4.15.0, )", + "resolved": "4.15.0", + "contentHash": "WbZpZI/gmrmTcb8JnP8poJBD4V6hJs2RRIQAmNxQ763t7RktZ/d6xxuU08XAd4K6SpnF0Bm02ADqL2wZ0CNIQA==" + }, + "NUnit3TestAdapter": { + "type": "Direct", + "requested": "[6.3.0, )", + "resolved": "6.3.0", + "contentHash": "8ZD+0dIfDHsL4w4Gl/LPxWH0O4ky3hFXqTZjY+ry0ThI3cMVErr9Tjxal5O5wJxGXh50a9qkDEAWVca8KQYCEw==", + "dependencies": { + "Microsoft.Extensions.DependencyModel": "8.0.2", + "Microsoft.Testing.Extensions.VSTestBridge": "2.3.3", + "Microsoft.Testing.Platform.MSBuild": "2.3.3" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.10.1", + "contentHash": "tV7tCyp/t+DuwQugLkCeUN7NBFFB4fEGRPr0gkMQ2nQgAvQQjmGlZZi1dsLzbyoe/A0mbnKybmkIh8qhT9p4Zw==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "mUBDZZRgZrSyFOsJ2qJJ9fXfqd/kXJwf3AiDoqLD9m6TjY5OO/vLNOb9fb4juC0487eq4hcGN/M2Rh/CKS7QYw==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.5.1", + "contentHash": "mu2iyoHovwl0q1mGeeRU0h8+LALXC7HqbxniLa1shTKYdJGl+KPoCEgjWIQ8MLzk1tlTGPOfg1JsxdH3RxhMCA==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.5.1, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.5.1", + "contentHash": "DYTZAguaHz0wNJdHDTTGYOXtnlDoLoIbTMDKSApNNcytm55DJcnwpwELfQGLk5DB5QlgaEjNhnGo1Pu5QvL5VA==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.5.1, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.5.1", + "contentHash": "FAMaFFWnULW+hDHMb40Qzhcgz9cAU2/EtgRCBNX/DdQoKo0sPG2EiY+w7bQdl/BgHnf/DbpXLbf5Xy0o15M8NA==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.10.1", + "contentHash": "o+PuLDRVp1iwiVAPBADZItXgm9Ck23XtPhE4kZtZVi0uYnMiiz1Dx9Cjh75kJ+ijfqnORL3VOTOaQVKi0f/ixg==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.10.1", + "contentHash": "KYz2omqkoz9E2mWa4fdgT+Ycie1wi5968EmyJlusjlp87RxxtA2FvDm8tcBEYIDzLnkWGnsKjE4d13Z7/rxkQA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.10.1" + } + }, + "trax.skills.publish": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/Trax.Docs/tools/Trax.Skills.Publish/GlobalUsings.cs b/Trax.Docs/tools/Trax.Skills.Publish/GlobalUsings.cs new file mode 100644 index 000000000..2967b6a36 --- /dev/null +++ b/Trax.Docs/tools/Trax.Skills.Publish/GlobalUsings.cs @@ -0,0 +1 @@ +global using System.Text.RegularExpressions; diff --git a/Trax.Docs/tools/Trax.Skills.Publish/Program.cs b/Trax.Docs/tools/Trax.Skills.Publish/Program.cs new file mode 100644 index 000000000..66455f13e --- /dev/null +++ b/Trax.Docs/tools/Trax.Skills.Publish/Program.cs @@ -0,0 +1,51 @@ +using Trax.Skills.Publish; + +const string usage = """ + Usage: + trax-skills check --skills + trax-skills stage --skills --version --license --out + trax-skills marketplace (prints .claude-plugin/marketplace.json as main holds it) + """; + +if (args is ["marketplace"]) +{ + Console.Write(Stager.MarketplaceJson()); + return 0; +} + +if (args.Length == 0 || args[0] is not ("check" or "stage") || args.Length % 2 == 0) + return Fail("expected a command and pairs of options"); + +var options = new Dictionary(StringComparer.Ordinal); +for (var i = 1; i < args.Length; i += 2) + options[args[i]] = args[i + 1]; + +if (!options.TryGetValue("--skills", out var skills)) + return Fail("--skills is required"); + +IReadOnlyList problems; +if (args[0] == "check") + problems = SkillRules.Check(skills); +else if ( + options.TryGetValue("--version", out var version) + && options.TryGetValue("--license", out var license) + && options.TryGetValue("--out", out var output) +) + problems = Stager.Stage(skills, version, license, output); +else + return Fail("stage needs --version, --license and --out"); + +foreach (var problem in problems) + Console.Error.WriteLine(problem); +if (problems.Count > 0) + return 1; + +Console.WriteLine($"{args[0]}: {SkillRules.Folders(skills).Count} skills, no problems"); +return 0; + +static int Fail(string why) +{ + Console.Error.WriteLine(why); + Console.Error.WriteLine(usage); + return 2; +} diff --git a/Trax.Docs/tools/Trax.Skills.Publish/SkillFrontmatter.cs b/Trax.Docs/tools/Trax.Skills.Publish/SkillFrontmatter.cs new file mode 100644 index 000000000..5767e8c19 --- /dev/null +++ b/Trax.Docs/tools/Trax.Skills.Publish/SkillFrontmatter.cs @@ -0,0 +1,110 @@ +namespace Trax.Skills.Publish; + +/// +/// The frontmatter of a SKILL.md: top-level key: value scalars and one +/// metadata: map of indented scalars. Anything else is a parse error. +/// +/// +/// A full YAML parser is deliberately not used, as in Trax.Adr.Guard. The subset is the +/// contract: the skills CLI and Claude Code both read these files, and a block either of them +/// could read differently from this one is a block nobody can check. +/// +public sealed class SkillFrontmatter +{ + private static readonly Regex TopLevel = new(@"^(?[a-z][a-z0-9-]*):(?: (?.*))?$"); + private static readonly Regex Nested = new(@"^ (?[a-z][a-z0-9-]*): (?.+)$"); + + private readonly Dictionary _scalars = new(StringComparer.Ordinal); + private readonly Dictionary _metadata = new(StringComparer.Ordinal); + + private SkillFrontmatter(string? error, string body) + { + Error = error; + Body = body; + } + + /// Null when the block parsed; otherwise why it did not. + public string? Error { get; } + + /// Everything after the closing --- line. + public string Body { get; } + + public IReadOnlyCollection Keys => _scalars.Keys; + + public IReadOnlyDictionary Metadata => _metadata; + + public string? Scalar(string key) => _scalars.GetValueOrDefault(key); + + public static SkillFrontmatter Parse(string text) + { + text = text.Replace("\r\n", "\n"); + if (!text.StartsWith("---\n", StringComparison.Ordinal)) + return new SkillFrontmatter("it does not start with a '---' frontmatter block", text); + + var end = text.IndexOf("\n---\n", 3, StringComparison.Ordinal); + if (end < 0) + return new SkillFrontmatter("its frontmatter block is never closed", text); + + var result = new SkillFrontmatter(null, text[(end + "\n---\n".Length)..]); + var inMetadata = false; + foreach (var line in text[4..end].Split('\n')) + { + if (inMetadata && Nested.Match(line) is { Success: true } nested) + { + if ( + !result._metadata.TryAdd( + nested.Groups["key"].Value, + Unquote(nested.Groups["value"].Value) + ) + ) + return Failed($"metadata key '{nested.Groups["key"].Value}' appears twice"); + continue; + } + + var top = TopLevel.Match(line); + if (!top.Success) + return Failed($"line '{line}' is not 'key: value' or an indented metadata entry"); + + var key = top.Groups["key"].Value; + var value = top.Groups["value"].Success ? top.Groups["value"].Value.Trim() : ""; + inMetadata = key == "metadata"; + if (inMetadata ? value.Length > 0 : value.Length == 0) + return Failed( + inMetadata + ? "'metadata' must be a map of indented entries" + : $"key '{key}' has no value" + ); + if (!inMetadata && !result._scalars.TryAdd(key, Unquote(value))) + return Failed($"key '{key}' appears twice"); + } + return result; + + SkillFrontmatter Failed(string why) => new(why, text); + } + + /// + /// The block written back with in place of the parsed map, the + /// scalars in the order gives, and the body unchanged. + /// + public string Render( + IReadOnlyList order, + IReadOnlyList> metadata + ) + { + var lines = new List { "---" }; + lines.AddRange(order.Where(_scalars.ContainsKey).Select(k => $"{k}: {_scalars[k]}")); + if (metadata.Count > 0) + { + lines.Add("metadata:"); + lines.AddRange(metadata.Select(m => $" {m.Key}: {Quote(m.Value)}")); + } + lines.Add("---"); + return string.Join('\n', lines) + "\n" + Body; + } + + private static string Unquote(string value) => + value.Length >= 2 && value[0] == '"' && value[^1] == '"' ? value[1..^1] : value; + + private static string Quote(string value) => + value is "true" or "false" ? value : $"\"{value}\""; +} diff --git a/Trax.Docs/tools/Trax.Skills.Publish/SkillRules.cs b/Trax.Docs/tools/Trax.Skills.Publish/SkillRules.cs new file mode 100644 index 000000000..e6572e944 --- /dev/null +++ b/Trax.Docs/tools/Trax.Skills.Publish/SkillRules.cs @@ -0,0 +1,157 @@ +namespace Trax.Skills.Publish; + +/// A rule a skill folder breaks, and which folder. +public sealed record SkillProblem(string Skill, string Message) +{ + public override string ToString() => $"{Skill}: {Message}"; +} + +/// +/// The rules a skill under Trax.Docs/skills/ is held to before a release publishes it. +/// See Trax.Docs/adr/0056-agent-skills-are-written-in-the-docs-and-published-from-a-release.md. +/// +public static class SkillRules +{ + /// The skills CLI's and Claude Code's limit on a skill name. + public const int MaxNameLength = 64; + + /// The Agent Skills limit on a description, which is what an agent matches a task against. + public const int MaxDescriptionLength = 1024; + + /// The scalars a source skill may declare. Anything else is a field one reader ignores and another acts on. + public static readonly IReadOnlyList Keys = ["name", "description"]; + + /// The metadata a release writes into each published skill. + public const string VersionKey = "trax-version"; + + /// The metadata that keeps a source skill out of npx skills add TraxSharp/Trax. + public const string InternalKey = "internal"; + + private static readonly Regex Name = new("^[a-z0-9]+(-[a-z0-9]+)*$"); + private static readonly Regex Link = new(@"\]\((?[^)\s]+)\)"); + + /// Every skill folder directly under , by name. + public static IReadOnlyList Folders(string skillsRoot) => + Directory.Exists(skillsRoot) + ? [.. Directory.EnumerateDirectories(skillsRoot).Order(StringComparer.Ordinal)] + : []; + + public static IReadOnlyList Check(string skillsRoot) + { + var problems = new List(); + if (Directory.Exists(skillsRoot)) + problems.AddRange( + Directory + .EnumerateFiles(skillsRoot) + .Select(f => new SkillProblem( + Path.GetFileName(f), + "a file directly under skills/ is not in any skill; put it in a skill's folder" + )) + ); + foreach (var folder in Folders(skillsRoot)) + problems.AddRange(CheckOne(folder)); + return problems; + } + + public static IReadOnlyList CheckOne(string folder) + { + var skill = Path.GetFileName(folder); + var problems = new List(); + void Add(string message) => problems.Add(new SkillProblem(skill, message)); + + foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)) + if (!file.EndsWith(".md", StringComparison.Ordinal)) + Add( + $"'{Path.GetRelativePath(folder, file)}' is not markdown. A published skill ships " + + "instructions only, never a script an agent could be told to run" + ); + + var path = Path.Combine(folder, "SKILL.md"); + if (!File.Exists(path)) + { + Add("there is no SKILL.md"); + return problems; + } + + var text = File.ReadAllText(path); + var front = SkillFrontmatter.Parse(text); + if (front.Error is not null) + { + Add($"SKILL.md frontmatter: {front.Error}"); + return problems; + } + + foreach (var key in front.Keys.Where(k => !Keys.Contains(k))) + Add($"frontmatter key '{key}' is not one of: {string.Join(", ", Keys)}, metadata"); + + var name = front.Scalar("name"); + if (name is null) + Add("frontmatter has no 'name'"); + else if (name != skill) + Add($"'name' is '{name}' but the folder is '{skill}'; the skills CLI installs by name"); + else if (!Name.IsMatch(name) || name.Length > MaxNameLength) + Add( + $"'name' must be lowercase words joined by hyphens, at most {MaxNameLength} characters" + ); + + var description = front.Scalar("description"); + if (string.IsNullOrWhiteSpace(description)) + Add("frontmatter has no 'description'; it is what an agent matches a task against"); + else if (description.Length > MaxDescriptionLength) + Add( + $"'description' is {description.Length} characters; the limit is {MaxDescriptionLength}" + ); + else if (description.Contains('<') || description.Contains('>')) + Add("'description' contains '<' or '>', which Claude Code rejects"); + + if (front.Metadata.GetValueOrDefault(InternalKey) != "true") + Add( + $"metadata has no '{InternalKey}: true'. Without it `npx skills add TraxSharp/Trax` " + + "offers this skill from main, before any release ships it" + ); + if (front.Metadata.ContainsKey(VersionKey)) + Add($"metadata carries '{VersionKey}'; the release writes it"); + foreach (var key in front.Metadata.Keys.Where(k => k != InternalKey && k != VersionKey)) + Add($"metadata key '{key}' is not one the release knows"); + + foreach (var target in RelativeLinks(text)) + { + var full = Path.GetFullPath(Path.Combine(folder, target)); + if ( + !full.StartsWith( + Path.GetFullPath(folder) + Path.DirectorySeparatorChar, + StringComparison.Ordinal + ) + ) + Add($"link '{target}' leaves the skill's folder, which is all that is published"); + else if (!File.Exists(full)) + Add($"link '{target}' names a file that does not exist"); + } + + return problems; + } + + /// Every link target that is neither absolute nor a bare anchor, without its anchor. + public static IEnumerable RelativeLinks(string markdown) => + Links(markdown) + .Where(t => + !t.StartsWith('#') + && !t.Contains("://", StringComparison.Ordinal) + && !t.StartsWith("mailto:", StringComparison.Ordinal) + ) + .Select(t => t.Split('#')[0]); + + /// Every markdown link target in , outside code fences. + public static IEnumerable Links(string markdown) + { + var inFence = false; + foreach (var line in markdown.Replace("\r\n", "\n").Split('\n')) + { + if (line.TrimStart().StartsWith("```", StringComparison.Ordinal)) + inFence = !inFence; + else if (!inFence) + foreach (Match m in Link.Matches(line)) + yield return m.Groups["target"].Value; + } + } +} diff --git a/Trax.Docs/tools/Trax.Skills.Publish/Stager.cs b/Trax.Docs/tools/Trax.Skills.Publish/Stager.cs new file mode 100644 index 000000000..f7c95e45b --- /dev/null +++ b/Trax.Docs/tools/Trax.Skills.Publish/Stager.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Trax.Skills.Publish; + +/// +/// Writes the tree the Skills workflow commits to the skills branch of TraxSharp/Trax: every +/// skill with its source marker removed and the release's version stamped in, the Claude Code +/// plugin manifest at that version, a README saying where the source is, and the licence. The +/// marketplace that points Claude Code at the branch is , kept at +/// the root of main. +/// +public static class Stager +{ + public const string Marketplace = "traxsharp"; + public const string Plugin = "trax"; + public const string Repository = "TraxSharp/Trax"; + public const string Branch = "skills"; + + /// The tag on the branch for a release: v* is the packages' and semantic-release's. + public const string TagPrefix = "skills-v"; + + public const string Source = "https://github.com/TraxSharp/Trax/tree/main/Trax.Docs/skills"; + + private const string Description = "Agent skills for building apps on Trax .NET"; + private static readonly Regex Version = new(@"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$"); + private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true }; + + /// + /// Stages the skills under into , + /// which must be empty or absent. Returns the problems that stopped it, empty on success. + /// + public static IReadOnlyList Stage( + string skillsRoot, + string version, + string licenseFile, + string outDir + ) + { + if (!Version.IsMatch(version)) + return [new("release", $"'{version}' is not a release version")]; + if (!File.Exists(licenseFile)) + return [new("release", $"licence '{licenseFile}' does not exist")]; + if (Directory.Exists(outDir) && Directory.EnumerateFileSystemEntries(outDir).Any()) + return [new("release", $"'{outDir}' is not empty; a stage writes a whole tree")]; + + var folders = SkillRules.Folders(skillsRoot); + if (folders.Count == 0) + return [new("release", $"there are no skills under '{skillsRoot}'")]; + var problems = SkillRules.Check(skillsRoot); + if (problems.Count > 0) + return problems; + + foreach (var folder in folders) + { + var target = Path.Combine(outDir, "skills", Path.GetFileName(folder)); + foreach ( + var file in Directory.EnumerateFiles(folder, "*.md", SearchOption.AllDirectories) + ) + { + var dest = Path.Combine(target, Path.GetRelativePath(folder, file)); + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + File.WriteAllText(dest, File.ReadAllText(file)); + } + + var front = SkillFrontmatter.Parse(File.ReadAllText(Path.Combine(folder, "SKILL.md"))); + File.WriteAllText( + Path.Combine(target, "SKILL.md"), + front.Render(SkillRules.Keys, [new(SkillRules.VersionKey, version)]) + ); + } + + var manifests = Path.Combine(outDir, ".claude-plugin"); + Directory.CreateDirectory(manifests); + Write(Path.Combine(manifests, "plugin.json"), PluginManifest(version)); + File.WriteAllText(Path.Combine(outDir, "README.md"), Readme(version, folders)); + File.Copy(licenseFile, Path.Combine(outDir, "LICENSE")); + return []; + } + + public static JsonObject PluginManifest(string version) => + new() + { + ["name"] = Plugin, + ["version"] = version, + ["description"] = Description, + ["author"] = new JsonObject + { + ["name"] = "TraxSharp", + ["url"] = "https://github.com/TraxSharp", + }, + ["homepage"] = "https://traxsharp.net", + ["repository"] = $"https://github.com/{Repository}", + ["license"] = "MIT", + }; + + /// + /// The marketplace at .claude-plugin/marketplace.json on main. It carries no + /// version: the plugin's version is the plugin.json on the branch it points at. + /// + public static JsonObject MarketplaceManifest() => + new() + { + ["name"] = Marketplace, + ["owner"] = new JsonObject + { + ["name"] = "TraxSharp", + ["url"] = "https://github.com/TraxSharp", + }, + ["metadata"] = new JsonObject { ["description"] = Description }, + ["plugins"] = new JsonArray( + new JsonObject + { + ["name"] = Plugin, + ["description"] = Description, + ["source"] = new JsonObject + { + ["source"] = "github", + ["repo"] = Repository, + ["ref"] = Branch, + }, + } + ), + }; + + /// The marketplace as the file on main holds it, newline included. + public static string MarketplaceJson() => MarketplaceManifest().ToJsonString(Indented) + "\n"; + + private static string Readme(string version, IReadOnlyList folders) => + $""" + # Trax .NET agent skills + + Skills for coding agents working in an app built on [Trax .NET](https://traxsharp.net), + as released in Trax {version}. + + **This branch is generated.** After each Trax release, an approved run of the Skills workflow + writes it from [`Trax.Docs/skills/`]({Source}) and tags it `{TagPrefix}{version}`. Change the + skills there, on `main`. + + ## Install + + Claude Code: + + ``` + /plugin marketplace add {Repository} + /plugin install {Plugin}@{Marketplace} + ``` + + Any agent the skills CLI supports: + + ``` + npx skills add https://github.com/{Repository}/tree/{Branch} + ``` + + To stay on one release, install from its tag: + `npx skills add https://github.com/{Repository}/tree/{TagPrefix}{version}`. + + ## Skills + + {string.Join("\n", folders.Select(f => $"- `{Path.GetFileName(f)}`"))} + + Each skill's `metadata.trax-version` names the release it matches. + + ## Licence + + MIT, see [LICENSE](LICENSE). + + """; + + private static void Write(string path, JsonNode node) => + File.WriteAllText(path, node.ToJsonString(Indented) + "\n"); +} diff --git a/Trax.Docs/tools/Trax.Skills.Publish/Trax.Skills.Publish.csproj b/Trax.Docs/tools/Trax.Skills.Publish/Trax.Skills.Publish.csproj new file mode 100644 index 000000000..b789d99de --- /dev/null +++ b/Trax.Docs/tools/Trax.Skills.Publish/Trax.Skills.Publish.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + Trax.Skills.Publish + false + true + + diff --git a/Trax.Docs/tools/Trax.Skills.Publish/packages.lock.json b/Trax.Docs/tools/Trax.Skills.Publish/packages.lock.json new file mode 100644 index 000000000..6afd6786b --- /dev/null +++ b/Trax.Docs/tools/Trax.Skills.Publish/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "dependencies": { + "net10.0": {} + } +} \ No newline at end of file diff --git a/Trax.Website/scripts/sync-docs.sh b/Trax.Website/scripts/sync-docs.sh index 728e70b94..eb306e7aa 100755 --- a/Trax.Website/scripts/sync-docs.sh +++ b/Trax.Website/scripts/sync-docs.sh @@ -26,11 +26,13 @@ mkdir -p "$CACHE_DIR" # # Everything under Trax.Docs is published EXCEPT the engineering-record trees below. # find descends into dotfile directories, so .claude/ must be named explicitly or the -# agent skill and the ADR format spec get public /docs/ routes. +# agent skill and the ADR format spec get public /docs/ routes. skills/ holds the agent skills +# for apps built on Trax, which a release publishes to the skills branch, not as pages. cd "$SOURCE_DIR" find . -name "*.md" -not -name "README.md" \ -not -path "./adr/*" \ -not -path "./.claude/*" \ + -not -path "./skills/*" \ -not -path "./tools/*" \ -not -path "./tests/*" \ -not -path "./.github/*" \