Development guide
How to change the plugin, check it and ship it. The rules for contributions are in CONTRIBUTING.md; this page explains the mechanics behind them.
Setup
Prerequisites on PATH: claude (Claude Code), python3 (3.8+, for the hooks and their tests), jq, gitleaks, rsync, ruby (for its YAML
parser), rg for the skills' own commands. No install step and no build.
Run the plugin from the working tree without installing it:
claude --plugin-dir plugin
Test a brain skill against a scratch vault, never a real one: cp -R plugin/vault-template "$(mktemp -d)/vault"
and point a Brain vault: line in that directory's CLAUDE.md at it.
The check
scripts/check.sh runs before every commit and in CI. It fails on the first error. Stages, in order:
| Stage | Fails when |
|---|---|
| JSON | Any *.json does not parse (jq empty) |
| Frontmatter | Frontmatter in plugin/, templates/, evals/ or docs/ Markdown does not parse; a docs/*.md lacks status (current, draft, deprecated), owner or a last-verified date; a skill, agent or output style lacks name/description or its name differs from the folder or file name; a line inside the frontmatter of an evals/ file contains --- (the eval loader ends frontmatter there) |
| Skill fields | A skill lacks license or compatibility, or its name does not match ^[a-z0-9-]{1,64}$ |
| Skill descriptions | A description is outside 80–1024 characters, or all together exceed 12000 |
| Skill sections | An action skill lacks ## Stop point or ## Never, or one is empty (a ## Never needs at least one - rule) (exempt: brain-search, code-review, plan-status, skill-usage, *-conventions) |
| Vault paragraph | The first paragraph under ## 1. Locate the vault in brain-capture, brain-compile, brain-garden or brain-review differs from brain-search's |
| Severity | A skill or agent that writes out the P0–P3 scale (inline P0 = … or four - **P0:** bullets) words it differently from the others |
| Local paths | A file under plugin/ or site/ contains /Users/, /home/, ~/www or .claude2 |
| YAML | A *.base or evals/*.yaml file does not parse |
| Hook targets | A ${CLAUDE_PLUGIN_ROOT}/… path in hooks.json does not exist |
| Agents | An agent lacks ## Final status or one of DONE, REVIEW, BLOCKED, CONTINUING |
| Surface | plugin/SURFACE.txt differs from the skills, agents, output styles, hook events and hook scripts on disk |
| Stale names | A skill, agent or output-style name removed from plugin/SURFACE.txt in the uncommitted diff (in a pull request, since the merge base with origin/$GITHUB_BASE_REF) still appears as a bare word (the prefixed form fails in References) in plugin/, templates/ or README.md; the check runs before the rename is committed |
| Counts | A count written as digits in the README, docs/, evals/README.md, the [Unreleased] section of CHANGELOG.md, CONTRIBUTING.md, SECURITY.md or a page under site/ ("N skills", "N eval cases"; not "N cases of") differs from the disk |
| Core-rules size | plugin/rules/core.md exceeds 8192 bytes, or CAP in state-card.py no longer fits under the 10,000-character hook output limit |
| References | A developer-os:<name> string in plugin/ (Markdown and hook scripts), docs/, templates/, evals/, README.md, CHANGELOG.md, CONTRIBUTING.md or SECURITY.md names no skill, agent or output style |
| Links | A relative Markdown link outside fenced or inline code does not resolve from its own file, or its #fragment matches no GitHub heading slug in the target Markdown file (letters of any script, link text only, -1, -2 for repeated headings) |
| Site | A doc, root document, skill, agent or output style has no page under site/ that names it in a dos-source meta; a page's hash differs from its source's; a relative href or src in site/, or a page in NAV in site/assets/site.js, does not resolve to a file inside site/ (in exact case) or names no id in the target page; a page names a source that belongs to another page; a page lacks its canonical URL or an og:image that exists under site/; site/sitemap.xml does not list exactly the pages' canonical URLs. It reports every failure at once |
| Script tests | Any tests/*.test.sh exits non-zero; tests/check.test.sh runs only when CI is set |
| Plugin validation | claude plugin validate . --strict or claude plugin validate plugin --strict fails when --strict exits non-zero, that is on any error or warning (such as an unknown manifest key) |
| Secrets | gitleaks dir finds a secret in tracked or untracked-not-ignored files |
The file set is tracked plus untracked-not-ignored files, so a new doc is checked before it is committed.
CI
.github/workflows/check.yml runs scripts/check.sh on pushes to development and main and on
every pull request. Before the check it runs agnix (agent-sh/agnix v0.57.0, the release binary pinned by its
SHA-256, --strict, target claude-code), a static lint of skills, hooks and manifests configured in
.agnix.toml; every rule disabled there carries its reason. It pins actions/checkout to a commit SHA, does not persist credentials, has
read-only contents permission, installs @anthropic-ai/claude-code@2.1.300 (with --ignore-scripts, then only its own install.cjs, which links the native binary, then claude --version) and a gitleaks
release verified by SHA-256, and, on a pull request, gitleaks git over the PR's commits, so a secret added and
removed inside one PR still fails. The same step fails a PR that changes .gitleaks.toml or
.gitleaksignore or adds a gitleaks:allow marker; the workflow file itself comes from the PR, so
only a branch ruleset that requires review of .github/ stops a PR that edits the step away.
The site
site/ is the public documentation site, hand-written HTML with no build:
index.html, the landing page;docs/, one page perdocs/*.mdat the same path, plusreadme.html,changelog.html,contributing.htmlandsecurity.htmlfor the root documents;skills/, an index and one page per skill;agents.htmlandoutput-styles.html;assets/site.cssandassets/site.js(the sidebar order inNAV, on-page contents, copy buttons);assets/og/, the 1200×630 link-preview cards (home,docs,skills,agents), and the favicons;sitemap.xml, one<loc>per page.
Every page's <head> carries, after its description, the link-preview block: canonical and og:url (https://msolecki.github.io/developer-os/<path>, a directory for an index.html), the favicon links (relative, from the page's data-root), og:type, og:site_name, og:title and og:description (the page's title and description), og:image (the section's card), its size and alt text, and twitter:card. A new page copies the block from a page in the same folder and adds its URL to sitemap.xml. The landing page also carries JSON-LD (SoftwareApplication). A project site has no robots.txt of its own (crawlers read only the host root), so the sitemap is submitted by hand.
Each page that restates a source carries <meta name="dos-source" content="<source>@<hash>">: the first 12 hex digits of the source's SHA-256 with any last-verified: line removed, so re-verifying a doc changes nothing. After you change a doc, a root document, a skill, an agent or an output style, the Site stage names the page to refresh: update its text, then its hash (sed '/^last-verified: /d' <source> | shasum -a 256 | cut -c1-12). A new doc or skill needs a new page, and a new doc an entry in NAV. The landing page and the skills index restate many sources and carry no hash; update them by hand when the hooks, rules or skills they describe change. CHANGELOG.md changes on most pull requests, so its page usually does too. The Local paths stage scans site/, so a page that quotes its patterns writes the slash as /, as development.html and contributing.html do. CI rejects a pull request that adds the gitleaks allow marker, so a page that quotes it writes its colon as :.
.github/workflows/pages.yml publishes site/ to GitHub Pages on a push to main that changes it, or by hand (dispatch it from main only, or it publishes unreleased docs). The repository's Pages source must be "GitHub Actions".
Script tests
tests/state-card.test.sh runs state-card.py in a temporary git repository and checks that it
prints nothing outside compact/resume, that the card holds the plan and state file only when they are
safe to show, and that the card is cut to its cap; tests/guard.test.sh feeds command fixtures to guard.py and expects ask or silence;
tests/capture-nudge.test.sh builds synthetic transcripts and checks the exit code, the JSON output and the message;
tests/check.test.sh breaks a copy of the repository once per stage it covers and expects that
stage's message; it runs the whole check about twenty times (around 40 s), so scripts/check.sh runs it
only when CI is set (GitHub Actions sets it): after changing scripts/check.sh, run sh tests/check.test.sh yourself;
tests/install-codex.test.sh runs the Codex installer against a temporary HOME (dry run,
idempotent second run, kept user text, unbalanced markers, foreign skills, uninstall, the Codex
budget of 8000 characters, counted in bytes); tests/skill-usage.test.sh runs the counting block from the skill-usage skill against synthetic
transcripts (a model call, the same call repeated with its uuid, a typed command, a quoted pattern)
and expects one count per message; when rg is missing it prints SKIP locally and fails in CI, which installs ripgrep.
After changing a hook, run its test from the repository root, for example sh tests/guard.test.sh,
and add a fixture for the case you changed.
Evals
Behavior checks with claude plugin eval, run by hand before a release or after changing a tested
skill, never in CI: evals/README.md. A case must fail when the skill breaks;
the README lists the mutation checks that prove it.
Adding or changing a skill
plugin/skills/<name>/SKILL.mdwithname: <name>and adescriptionthat says what it does and when to use it, including the phrases the user would type. Keep within the description budget;scripts/check.shprints the total when it fails.- If it can write, send or change git state, end with
## Stop pointand## Never. If it is read-only and should be exempt, add it toread_onlyinscripts/check.sh. - Use synthetic examples only; no local paths, client names or real vault notes.
- Reference other parts as
developer-os:<name>; the check resolves them. A bare name inplugin/is checked only during a rename: the Stale names stage fails, before the rename is committed, on any name removed fromSURFACE.txt. - A brain skill starts with the shared vault lookup: the check compares the first paragraph under
## 1. Locate the vaultacross the five brain skills that have it, so change all five together. - Add an eval case when the skill has a behavior worth protecting (a gate, a refusal, a trigger).
- Add the name to
plugin/SURFACE.txt(the check prints the line), and update the README component list,CHANGELOG.md, and product/functional-spec.md. Counts written as digits are checked; fix any the check reports.
Changing plugin/rules/core.md: it sits just under the 8192-byte cap (wc -c < plugin/rules/core.md).
Every added line must replace or shorten another; run instruction-review --prune on it inside this repository.
Codex installer
scripts/install-codex.sh is not part of the plugin; users run it from a clone. After changing a skill
in a way that adds a Claude-only reference (a new ${CLAUDE_*} variable, a new agent dispatch), check
that the rewrite in the script still covers it: sh tests/install-codex.test.sh greps the copies.
A skill description must be one plain line: no > or | block scalar, no quotes, no continuation
line; the script refuses any other form.
Branches and releases
mainis the default branch and what the marketplace installs; it moves only on a release (D20).developmentis the work branch. Work lands there through pull requests; the repository disallows merge commits.plugin/.claude-plugin/plugin.jsonand.claude-plugin/marketplace.jsoncarry the sameversion; the Validate stage fails when they differ. Claude Code caches an install by that version, so an update reaches users only when it changes.CHANGELOG.mdfollows Keep a Changelog. A user pins a release by its tag:/plugin marketplace add msolecki/developer-os#v<version>.
Release, by hand:
- On a branch from
development: bumpversionin both manifests, rename[Unreleased]inCHANGELOG.mdto[<version>] - <date>under a new empty[Unreleased], refresh the site pages the Site stage names, runbash scripts/check.sh, and merge the pull request. - When
checkis green ondevelopment:git push origin origin/development:main. Only a fast-forward is accepted; themainruleset lets the repository admin push without a pull request (bypass mode "Always"), somainkeeps the same SHAs asdevelopment. git tag -a v<version> -m v<version> origin/main && git push origin v<version>at once (a fetch prunes an unpushed tag; a lightweightgit tagstops with "no tag message?" wheretag.gpgSignis set), thengh release create v<version> --notes-file <the changelog section>. Therelease-tagsruleset forbids moving or deleting av*tag.- Pushing
mainpublishessite/to GitHub Pages.
Plans
docs/plans/ is git-ignored. Plans for this repository live there while a task runs and are deleted
when it is done, per the core rules.