yuzu

Plugin README Standard

Every agent plugin carries its own documentation beside its code: agents/plugins/<name>/README.md. A reader must be able to learn how a plugin works, on which operating systems, what it calls, and what data it sends and receives, without opening the source. The same file is the source of a machine-readable manifest the server serves to agentic clients over REST and MCP (rule 10), so its shape is a contract, not a style preference.

The skeleton to copy is docs/templates/plugin-README.md. The generator is tools/plugin-doc-gen/plugin_doc_gen.py; the capture tool is tools/plugin-capture. The gate is tests/test_plugin_readmes.py (meson docs suite) plus the plugin-readme-touch-rule job in .github/workflows/docs-lint.yml.

The standard, in ten rules

  1. One README.md per plugin directory — agents/plugins/<name>/README.md. It is the plugin’s single human-facing document. docs/user-manual/agent-plugins.md carries a generated index that links here; the retrospective sweep moves its remaining per-plugin prose into the READMEs, after which the catalog carries none of its own.

  2. Eight sections, fixed order, fixed headings (the section contract below). A reader finds the same thing in the same place on every plugin page. All eight are mandatory. A section with nothing to say states that in one line (“Neither action takes parameters”) and is never omitted.

  3. Generated fences are never hand-edited. Everything between <!-- BEGIN GENERATED: plugin-doc-gen <block> --> and <!-- END GENERATED --> is emitted by the generator from code-adjacent sources: the CI-verified capability-matrix block in docs/os-capability-matrix.md (the plugin’s declared per-OS legs), content/definitions/<name>.yaml, server/core/src/capability_decls/*.hpp, and the plugin directory itself. CI byte-diffs the fences. To change one, change its source and regenerate.

  4. Hand-written sections describe the code as it is today, not the intent. Present tense. “Planned” and “will” appear only under Caveats and known gaps. A claim that cannot be verified by reading the cited source, or by running the capture, does not go in.

  5. Sample output is a real capture on every leg the descriptor declares supported or constrained, produced by plugin-capture through the agent’s real LocalDispatcher, stored at agents/plugins/<name>/docs/samples/<os>.txt and spliced by the generator. The first line is the stamp: captured: <os> <os-version> · <host-class> · <date> · <privilege> · leg-hash <hash>. Host class is bare-metal, vm or container. Unsupported legs show the placeholder row the plugin actually emits. A leg that cannot honestly be captured is recorded as == action=<name> followed by one line [not captured] <class>: <reason> and no result-status line, where <class> is one of <DispatchClass>/<Mutability> (a destructive mutator that must never run on a live host — the gate cross-checks this class against the plugin’s own capability row), agent-context (the plugin’s init needs the agent’s KV store, trigger engine or server, which plugin-capture does not provide — its own stderr says so), or hardware-absent (the leg needs hardware none of the capture hosts has, e.g. a Wi-Fi adapter in a container); the generator and the gate accept that marker as the sample. The latter two classes are not independently verified — nothing stops a lazy agent-context on a leg that would in fact capture cleanly — so a reviewer sees the reason string and judges it the way any other claim in the README is judged (rule 4). Every other action the descriptor declares supported or constrained on that OS must appear in the file — the gate checks per leg, not per file. No fabricated rows, ever. Captures are taken only on hosts the contributor owns or is explicitly authorised to publish from — a personal machine, a lab host, a throwaway container — never a customer, pilot or production fleet host, and never a colleague’s machine without their agreement. A capture is published under the repository licence and served to every Infrastructure:Read holder, so treat every row as public: host identifiers a device carries (volume GUIDs, drive models, installed-application rule names) may stay; secrets and personal data (account names, e-mail addresses, user-profile paths, anything tying a device to a person) are redacted, never a row’s structure, and the redaction is noted in the stamp’s privilege field. That field names a role or an id (euid 0, interactive user (elevated), NT AUTHORITY\LOCAL SERVICE), which is what plugin-capture measures by default — never a person.

    Personal-data guard (CI-enforced, docs-lint). scripts/ci/check-personal-data.py scans the committed content of tracked files (read from git, so a symlink’s target string is scanned too; binary files also as UTF-16) and fails the PR on a new hit. NOT scanned: governance.d/ (append-only), CHANGELOG.md (never edited), generated *.pb.*, vendored trees, blobs over 20 MiB, and submodules; every run prints a one-line count of skipped files by reason (excluded, oversize, unreadable, submodule). Output carries only path:line and the detector, never any part of the value. Detectors: home-path (/Users/<u>/, /home/<u>/, C:\Users\<u>\; synthetic names such as jsmith, runner, Public are allowed everywhere; persona names such as alice and bob are allowed only under tests/unit/ outside fixtures and samples), windows-sid (use S-1-5-21-1111111111-2222222222-3333333333), tailnet-ip (use 100.64.0.x or 100.100.100.100; fd7a:115c:a1e0::N), mac-address (samples, fixtures, unit tests, plugin READMEs and content/plugin-docs/ only; use 00:00:5e:00:53:xx or a locally administered 02:..), private-key, personal-email (consumer mailbox domains; use example.com) and tracked-session-artifact (.claude/plans/, .serena/, .claude/projects/). Two reviewed escape hatches: a same-line personal-data-ok: <reason> marker (reason of at least 10 characters), or an entry in scripts/ci/personal-data-baseline.json (pre-existing hits as an allowed count per path and detector, max_hits; no value or hash of a value is stored, because a hash of an IP, MAC or short username is brute-forceable). A file fails when its hit count exceeds max_hits or it has hits and no entry. Known, accepted limit: a baselined file can swap one value for another of the same detector without failing, until the baseline is emptied; the baseline is meant to shrink to empty, never grow. Regenerate with --update-baseline; fewer hits than max_hits is reported as stale and fails under --strict-baseline. CI runs --strict-baseline --ratchet, so a PR that removes a baselined hit must also lower that entry, and the baseline may only shrink against its merge-base with the PR’s target branch: a baselined file that git detects as renamed (git diff -M, 50% similarity by default) keeps its allowance under the new path, and the per-path, per-detector count still may not grow; a move git does not detect as a rename (heavily rewritten, a copy, or past the rename limit) is a new entry, so fix the hit or mark the line with personal-data-ok: instead. The full allowlists are documented in the script header. Self-test: tests/test_check_personal_data_selftest.py.

  6. The leg-hash on a capture stamp must match the current legs and column schema. The generator computes it from the plugin’s declared legs (support, rung, mechanism per action per OS) and its definitions’ result.columns (name, type); the descriptor’s fallback prose and spec.parameters are excluded, so a wording edit or a new parameter never forces a recapture. A mismatch fails the gate until the leg is recaptured: plugin-capture writes leg-hash pending, plugin_doc_gen.py --stamp <name> <os> writes the current hash, and the next --all splices it. --stamp cannot tell a fresh capture from a stale one — it trusts the author, and a reviewer checks the capture date. leg-hash pending is accepted with a warning for a hand-authored capture that predates regeneration. Plugin versions are not used as the freshness signal because they are bumped inconsistently.

  7. Touch rule. A pull request that changes agents/plugins/<name>/src/** must also change agents/plugins/<name>/README.md, or carry a line docs-unchanged: <section it would have touched> — <reason> in its body, outside any code fence or HTML comment so the reviewer sees it on the rendered page. The plugin-readme-touch-rule job prints the override; it is advisory until the repository owner adds it to the required checks (docs/ci-architecture.md), so a reviewer reads its log as part of the review. A plugin that has no README yet is exempt until it gains one; the README-existence ratchet in tests/test_plugin_readmes.py governs that count and may only shrink.

  8. Output field tables come from the definition YAML. spec.result.columns[] accepts four optional keys documented in docs/yaml-dsl-spec.md: description, values (the closed vocabulary a string column may carry), example, and platforms (a list drawn from windows, linux, darwin). The generator validates their shapes and fails --all/--check on a scalar where a list was meant, an unknown platform, or values on a non-string column. A parameter’s validation block (enum, pattern, length and range bounds) renders into the Inputs table’s Constraints column. A missing key renders as -. The server ignores keys it does not read.

  9. Regenerate with one command and no build. python3 tools/plugin-doc-gen/plugin_doc_gen.py --all rewrites every README fence, the catalog index, the site navigation fragment and the manifests from the committed sources; meson compile -C <builddir> docs-regen wraps it. Only captures need a built plugin, and only on that leg’s operating system. The capability-matrix block itself is regenerated on its canonical Linux host, as before — and first: the generator reads that block, not the binaries, so after a per-OS leg change regenerate the block on Linux, then run --all, or the READMEs and leg-hashes will match a stale block and the two gates will disagree. --check runs every generator-side check (fence byte-diff, section shape, per-leg sample coverage, leg-hashes); the docs suite adds the README-existence ratchet and the manifest-key binding on top.

  10. The README is the source of the machine manifest; the eight headings are the parse contract. The generator writes content/plugin-docs/<name>.json (manifest_version 1) from the generated blocks and from the hand-written sections, which it locates by heading. The server embeds those files at build time and serves them as GET /api/v1/discover/plugin-docs and the MCP resource yuzu://plugin-docs, and joins a per-plugin summary into discover_plugins. Renaming a heading, or nesting a section differently, breaks that parse and fails the gate. Hand-written tables are parsed positionally, so a literal pipe inside a cell is written \| (GFM’s escape); the gate checks the contracted column count of the privileges (5) and result-status (4) tables. There is deliberately no dedicated MCP tool for the manifests: the REST route + byte-identical resource pair follows the specs-as-resources precedent (yuzu://scope-dsl), and the tool-level entry point is the docs summary discover_plugins already carries. A per-plugin resource (rather than the one whole-catalog body) shipped once the retrospective sweep gave real size data to design against: GET /api/v1/discover/plugin-docs/{name} and the MCP resource template yuzu://plugin-docs/{name} (#4108), sharing the whole-catalog builder’s manifest_by_name index.

Section contract

#HeadingKindSourceContent rule
1(title block) # <name> + property tableGENERATED headerdescriptor identity (name, version, description) from the plugin source; actions and per-OS support from the capability-matrix block; securable, operation, risk, dispatch class and gate from capability_decls; definition ids, roles and gather mode from the YAMLWhat it does · Version · Kind (collector or action; on-demand or gathered) · Platforms (✅ supported · 🟡 constrained or planned · ⛔ unsupported, per OS) · Actions with definition ids · Security · Roles
2## How it worksHANDauthor5–10 lines, present tense: what each action reads or changes, in what order, and what it is deliberately not (scope rulings). One mermaid flowchart LR from trigger → OS call per leg → rows and status → server store → API.
3## OS capabilityGENERATED capabilitycapability-matrix blockCompact action × OS matrix (support · rung · mechanism), then the declared limits per leg verbatim as a bullet list.
4## Privileges and prerequisitesHANDdocs/agent-privilege-model.md row and the leg source headersA table with columns OS · Runs as · Extra grant needed · Measured · If the read is refused and one row per OS (Windows, macOS, Linux — “n/a” where the leg does not exist), then one line naming binaries, subprocesses and network use, or “none”. The gate checks the three rows and the five-cell width.
5## Data contractGENERATED inputs + outputs, HAND subsectionsYAML spec.parameters; YAML result.columns with the optional keys; hand tables for ### Result status and ### Where the data goesSubsections in this order: ### Inputs (fence), ### Outputs (one hand paragraph on row format and placeholders, then the fence), ### Result status (hand table Status · Completeness · Provenance · When — one row per status/completeness pair the plugin can report, listing every provenance literal it emits for that pair, so a consumer can key on the column; the gate reads every token passed to a status call in the plugin’s sources — <os>:<token>, <plugin>:<token>, the literal prefix of a formatted token — plus the subprocess_runner:* tokens of runner_status.hpp when the plugin includes it, and fails on one the table does not name), ### Where the data goes (hand bullets, at least two: the instruction result with its store and retention, Not consumed by for daily-sync, Echo, DEX and metrics, a **Sensitivity.** bullet saying what in the rows could identify a device, a person or installed software (the gate checks it is present), and siblings).
6## Sample outputGENERATED samplesdocs/samples/<os>.txt files written by plugin-captureOne block per OS, stamped. The generator trims each action to 12 rows and says so.
7## Caveats and known gapsHANDauthor, review threadsOne to five numbered items, each opening with a bold lead and one or two sentences (the gate checks the count and the lead). Rejected mechanisms and “do not reintroduce” invariants live here.
8## Source and testsGENERATED sourceplugin directory, definition YAML, capability fragment, tests, privilege-model row, changelog fragmentsPaths only, one per line.

Tone: concise plain English readable at senior-executive level, present tense, no emojis in prose (the ✅ 🟡 ⛔ legend is the one exception, matching the capability matrix), no attribution lines.

Manifest schema (content/plugin-docs/<name>.json, manifest_version 1)

KeyFromShape
manifest_version, name, version, descriptionheaderscalars — read from the checked-out tree only, never from git history, so the byte-gate is host-independent
kindcapability rows{"collector": bool, "mutating": bool, "gathered": bool}
platformscapability-matrix block{"windows"|"linux"|"macos": "supported"|"constrained"|"planned"|"unsupported"} (best support across actions)
securitycapability_decls[{"action","securable","operation","risk_tier","dispatch_class","mutability","execute_gate"}]
actionscapability-matrix block + YAML[{"action","definition_ids":[…],"legs":{"<os>":{"support","rung","mechanism","fallback"}}}]
definitionsYAML[{"id","display_name","description","platforms","approval_mode","execute_roles","author_roles","gather"}]
inputsYAML spec.parameters[{"definition_id","name","type","required","default","constraints","description"}] — constraints is the parameter’s validation object verbatim, or null
outputsYAML result.columns[{"definition_id","columns":[{"name","type","description","values","example","platforms"}]}]
how_it_worksREADME §2text with the mermaid fences removed, ### subsections included (everything up to the next ##)
outputs_noteREADME §5 ### Outputs hand paragraphtext: the row format, discriminator field and placeholder convention a consumer needs to map samples[].rows onto outputs[].columns
privilegesREADME §4 table[{"os","runs_as","grant","measured","if_refused"}]
result_statusREADME §5 table[{"status","completeness","provenance","when"}]
where_the_data_goesREADME §5 bullets[string]
samplessamples files{"<os>": {"stamp": {...}, "actions": [{"action","params","rows":[first 5],"row_count","result_status","not_captured","truncated","rc"}]}}
caveatsREADME §7[string]
sourcesource block{"plugin":[…],"definitions":[…],"capability_rows":[…],"tests":[…],"privilege_row":bool,"changelog":[…]}
readmepathagents/plugins/<name>/README.md
leg_hashcomputedthe 12-hex digest of rule 6, so a consumer can tell which legs a sample stamp was taken against

Capturing on each OS

Every capture is the built plugin-capture binary run on the OS it stamps (tools/plugin-capture/, target plugin-capture, built with -Dbuild_agent=true, the default). It measures the OS version and the privilege itself; pass --os-version/--privilege only to override.

Authoring a README before the generator has run

Fill each fence from its named source exactly as the generator will: the header from the plugin source and the capability fragment; the capability matrix from your own kActionDescriptors (support, rung, mechanism; fallback verbatim); inputs and outputs from your definition YAML (add the four optional column keys while you are there); samples from a real run through LocalDispatcher on each supported OS, stamped with leg-hash pending. The next --all run replaces the fences byte-for-byte and fills the hash.