Documentation System
This document is the design record for how Hegemony documentation is produced, published, and — most importantly — kept in sync with the code it describes. It covers four connected surfaces:
- Inline code documentation — docstrings, JSDoc, and schema descriptions.
- The public docs website — usage and architecture docs on hegemony.sh.
- In-platform help — documentation reachable inside the running UI.
- Freshness automation — the machinery that makes documentation drift a CI failure instead of a slow decay.
The core idea
Documentation rots because it is a parallel artifact with no mechanical connection to the code. Hegemony already solved this problem once, for the API contract: apps/api/openapi.json is a committed generated artifact, CI regenerates it and fails on diff (task api:verify-openapi-sync), pre-commit auto-fixes it, and every failure message names the command that repairs it.
The documentation system generalizes that pattern:
- Reference docs are compiled, not written. Anything with a machine-readable source of truth (settings, RBAC, enums, registries, Taskfile) is generated into
docs/reference/and gated exactly like the OpenAPI artifact. - Prose docs are bound to code.
docs/docs-map.yamlmaps every UI route and backend subsystem to its documentation and the source paths it describes. A meta-test keeps the map complete; a CI gate fails PRs that change mapped sources without touching the mapped docs. - Factual claims in prose are linted. Environment variables, task names, API paths, handler ids, and permission names mentioned in Markdown are checked against the sources of truth.
- One source, many surfaces. The website and the in-app help render the same files from
docs/; they cannot diverge from each other.
Blocking enforcement is deterministic only: generators, diffs, meta-tests, and linters - nothing an AI says gates a merge. An advisory AI review complements them for exactly the gaps deterministic gates cannot check (see "What these gates cannot see"); its findings arrive as issues for a human to judge, never as merges. Humans (and whatever tools they choose to use) do the writing.
Pillar 1 — Generated reference pages
scripts/generate-docs.py (emitters in scripts/docs_gen/) compiles the pages under docs/reference/, plus the documentation index:
| Page | Source of truth |
|---|---|
reference/environment-variables.md | packages/core/settings.py (Settings model fields and descriptions) |
reference/rbac.md | apps/api/auth/actions.yaml and apps/api/auth/routes.yaml via the loader in apps/api/auth/permissions.py |
reference/vocabulary.md | packages/core/enums.py |
reference/maintenance-jobs.md | The maintenance job registry in apps/api/services/maintenance/__init__.py |
reference/step-handlers.md | apps/ui/e2e/fixtures/step-handler-types.json (the committed demo/e2e handler contract) |
reference/commands.md | desc: fields in Taskfile.yml and deploy/compose/Taskfile.yml |
index.md | The documentation tree itself, plus each page's H1 |
Workflow:
task docs:generate— regenerate all pages (also runs as a pre-commit auto-fix hook when a source of truth changes).task docs:verify-sync— regenerate to a temp dir and diff against the committed pages; CI runs this in thepython-checksjob (its docs-integrity steps).tests/docs/test_reference_sync.py— renders each emitter in-process and asserts equality with the committed file, so plaintask testcatches drift too.
Escaping is the writer's job, not the emitter's
escape_cell in scripts/docs_gen/common.py neutralizes markup outside code spans in every table cell, so no emitter can forget to. Two classes of text need it, and both arrive from outside this repo — most importantly step-handler titles and field descriptions read from installed plugin wheels:
<word>is a valid HTML tag to CommonMark.sh -c <script>rendered assh -cwith the rest silently swallowed; on a site that passes raw HTML through, it emits a live<script>tag. Six such spans were live in the generated pages.{{ }}is interpolation to Vue, which the documentation site is built on. One handler's description readUse {{ variable }} syntax for flow inputs, which would have broken that page at integration time.
Content inside code spans is left verbatim — renderers already treat it literally, so escaping there would show the reader entity text. The pipe escape and newline collapsing that cells always needed are unchanged.
The index cannot go stale
docs/index.md is generated from the tree and each page's H1, so it lists every page and only pages that exist. It replaced README's hand-maintained "Documentation Navigation" list, which was already wrong: it knew nothing of docs/guides/ or docs/reference/ and pointed at bare directories. A new docs subdirectory fails the render until it gets a heading and a blurb in SECTIONS, rather than having its pages silently dropped.
Determinism contract
Generated output must be byte-stable so diffs mean something:
- All iteration is sorted by stable keys; embedded JSON uses
sort_keys=True. - No timestamps and no version strings in generated output — this keeps release automation (
scripts/bump-version.sh,.releaserc.json) out of the loop entirely. - LF line endings, exactly one trailing newline, file mode
644. - Output passes markdownlint under the repo config;
docs/reference/is not added to any ignore list. - Every generated file starts with the SPDX header followed by a
GENERATED FILE — DO NOT EDITbanner naming the regeneration command and the sources.
Why the step-handler catalog uses the e2e fixture
Step handlers ship as out-of-tree plugin wheels. apps/ui/e2e/fixtures/step-handler-types.json — a real GET /flows/step-handler-types response — is the page's input rather than the live registry, so generation is deterministic and does not depend on which optional wheels the generating environment happens to have installed.
The fixture is only trustworthy because it is gated. An earlier revision of this section claimed the repository "cannot introspect [plugins] at docs-build time"; that was wrong — the python-checks CI job already checks out the pinned sibling repos and runs uv sync --frozen --all-extras, so every pinned wheel is importable during generation. Because nothing verified the fixture against the registry, it drifted: 19 config fields across 13 handlers were missing from the page, including container.run's egress_mode / egress_allow / egress_deny outbound-firewall controls and five of evidence.compare's seven fields. The sync gate passed throughout, because it only ever proved page == fixture.
The gate that was missing:
| Gate | What it proves |
|---|---|
task docs:verify-sync | the committed page equals a fresh render of the fixture |
task docs:verify-fixtures | the committed fixture equals a live dump of the installed wheels |
tests/docs/test_step_handler_fixture_sync.py | the same comparison per handler, naming the drifted fields |
task docs:fixtures:refresh regenerates the fixture and the page together. A .github/plugin-pins.json bump changes the documented handler set, so the pins file is a docs_sources trigger in CI and in the docs-generate hook (asserted by tests/deploy/test_ci_plugin_pins.py).
The page documents the pinned wheel set, which is not necessarily what a given deployment installs — the page says so, and a live instance self-documents its real plugin set at Settings → Installed Plugins. Closing that remaining gap properly is the subject of Plugin-owned documentation below.
The Configuration Exchange template: from gated to generated
YAML_SCHEMA_TEMPLATE — the annotated YAML the Load Template button puts in the Configuration Exchange editor — began as a hand-written literal bound to its schema only by a gate. The gate's first run showed why binding matters: the template omitted four whole resource sections, sixteen fields, and an event value the importer rejects. It has since been converted to a hybrid generator, apps/api/services/config_exchange_template.py:
- Derived, so it cannot drift: the section set (building raises when the specs and the envelope disagree), the section order and the header's import-order line (both from the importer's real apply order), default values (interpolated from the actual constants), and a commented stub for every model field the examples do not show, its text drawn from the field's pydantic
description. - Authored, because prose beats introspection: each section's example block - the cross-referencing tutorial no schema can invent - lives as data in the generator module, next to the envelope contract.
tests/docs/test_config_exchange_template.py still holds the assembly to the import schema (validates against ImportBundle, every section and field present, sections in apply order) and requires a description on every bundle field, since the stubs and the generated configuration-exchange reference render from them. Completeness of the format itself is a separate gate: tests/docs/test_config_exchange_completeness.py classifies every ORM model as either covered by an envelope section or excluded for a stated reason (run history, credentials, provider caches, file-store contents), so a new configurable resource cannot ship outside the export format - the one-to-one-recreation promise is tested, not assumed.
Pillar 2 — The docs map and freshness gate
docs/docs-map.yaml is the single binding between code and prose. It has four sections:
ui_routes:— one entry per route inapps/ui/src/App.tsx(keys literally match thepath:values). Each entry names the page component, the doc page(s) covering it, and optional extrawatch:globs. Routes that genuinely need no docs declaredocs: nonewith a mandatoryreason:— which doubles as the visible documentation backlog.subsystems:— backend areas without a 1:1 route (webhooks, notifications, …) with their docs and watched source globs.generated:— the Pillar-1 pages, so they are not orphans (their freshness is handled bydocs:verify-sync, which is strictly stronger).unmapped_ok:— cross-cutting docs (glossary, this document, …); each entry names apathand a mandatoryreason, keeping this escape hatch as auditable asdocs: none.
Three consumers enforce and use the map:
- Completeness meta-test (
tests/docs/test_docs_map.py): route set inApp.tsxequals the map's route set; all referenced files exist; allwatchglobs match at least one tracked file; everydocs/**/*.mdis accounted for. Adding a UI route without a map entry failstask test. - Freshness gate (
scripts/check-docs-freshness.py, the docs-freshness step of thepython-checksCI job, PR-only): if a PR changes an entry's watched sources but none of its mapped docs, the job fails and names the entry, the changed files, and the three exits — update the doc, narrow thewatchglob, or override. - Help-content generation (see Pillar 5): the same map compiles into the route → documentation bundle rendered by the in-app Help drawer.
Override semantics
A PR that legitimately does not need doc updates (pure refactor, typo fix in a watched file) can override the freshness gate in either of two equivalent ways:
- add the
docs-not-neededlabel to the PR (visible and auditable in the PR UI), or - include a
Docs-Not-Needed: <reason>trailer in any commit message in the PR (works offline and from the CLI).
Overridden runs still print a notice into the job log, so skips are on the record. Overuse of the override is the main cultural risk of this system; maintainers should review git log --grep 'Docs-Not-Needed' periodically (quarterly is a good cadence) and tighten watch globs where the gate fires spuriously.
What these gates cannot see
Name the boundary honestly: the freshness gate is change-coupling, not truth-checking. It forces the guide to be touched when a watched source changes; nothing mechanical verifies the guide's claims about the UI ("the header shows five tabs"). The claims linter (Pillar 3) checks tokens against sources of truth, not behavior. A page can pass every gate in this document and still describe a screen that no longer works that way — exactly the class of error found when the plugin doc pages were adversarially fact-checked against their handler source (23 errors that drafting care and every mechanical gate had missed).
The honest mitigations are: generated screenshots (Pillar 6) force a periodic look at the real screen; and an adversarial verification pass — a reviewer, human or machine, re-deriving each behavioral claim from the implementation and trying to refute it — run when a guide is written and occasionally thereafter. That pass demonstrably out-performs both drafting care and mechanical gates, so it is now a standing, automated step, run as GitHub Agentic Workflows on the GitHub Copilot engine (inference billed to the repository's Copilot plan through the copilot-requests permission - no API-key secret):
.github/workflows/docs-ai-review.md- weekly and on dispatch, the deep review: semantic drift re-derived from the code, missing coverage, audience separation, cross-page coherence. Findings land on a labeled issue; a clean run files nothing..github/workflows/docs-pr-review.md- on pull requests touchingdocs/, the adversarial pass over exactly the changed prose, plus an audit of anydocs-not-neededoverride the PR carries. Findings land as one PR comment; a clean run stays silent..github/workflows/audit-pr-review.md- on pull requests touching API handlers, services, or the audit machinery, the review of audit entry quality the audit coverage gates cannot judge: emit placement, real before/after snapshots, complete field lists, details that answer "what changed", honest registry reasons. Findings land as one PR comment; a clean run stays silent.
The .lock.yml files beside them are the compiled artifacts GitHub Actions executes - regenerate with gh aw compile (the gh-aw extension) after editing the Markdown, and commit both. Everything the compiled workflows run is pinned (actions by SHA, containers by digest), agent traffic goes through gh-aw's egress firewall, and writes happen only through declared safe outputs (one issue, one comment). Advisory by design: only the deterministic gates block.
Audience policy: user-facing sections (docs/guides/, docs/features/, docs/reference/, docs/operations/, docs/deployment/, top-level pages) are what the app bundles as in-app help; developer sections (docs/architecture/, docs/development/) are web-only, and the help emitter fails generation if the docs-map ever routes one of them to a screen. In-app links to web-only pages open the documentation site at the page's deep URL.
Pillar 3 — Claims linting
scripts/check-doc-claims.py (task docs:verify-claims) validates factual tokens in Markdown prose against the sources of truth:
- Tier 1 (blocking):
HEGEMONY_*tokens must name a real environment variable — aSettingsfield or a variable used anywhere in tracked source/config files; namespaced`task <name>`mentions must exist in the Taskfiles. - Tier 2 (warnings): backticked
/api/...spans must match a path inapps/api/openapi.json(parameter names are normalized); backticked handler ids under known namespaces must exist in the step-handler fixture. Follow-ups (promotion to blocking, more checks) are tracked inTODO.md.
Two further Tier-1 checks exist because auditing the docs found whole classes of staleness the checks above are structurally blind to:
- Cited repo paths must exist. Any code span naming a path under
apps/,packages/,scripts/,tests/,deploy/or.github/is checked againstgit ls-files. This found 20 dead citations across 11 files — modules renamed by the Configuration Exchange work, an ignore-file mechanism that never existed, sibling-repo paths written as if local. Code spans only, and only under real top-level directories: fenced blocks legitimately name runtime paths, and sibling-repo sections name paths relative to that repo (write those ashegemony-step-plugins/tests/...so both the reader and the gate can tell). - Retired handler ids must not be cited. The old→new maps in the alembic rename migrations (
RENAMES) are harvested directly, so no list needs maintaining. This is the gapcheck_handler_idscannot cover: it only inspects dotted spans whose namespace is still registered, so whenconnectivity_monitor.*becamemonitor.*the old namespace left the truth set and its seven surviving references stopped being claims at all.
A third Tier-1 check guards the reader rather than the facts: prose must survive rendering. The same <word> and {{ }} hazards that escape_cell handles for generated tables can be typed by hand, so any of them outside code spans and fences is an error, with the fix in the message (wrap it in backticks). This check is the one that also runs over docs/reference/, because those pages' prose is partly plugin-supplied.
Getting it right required matching CommonMark on code spans: the naive document-wide match let a single unpaired backtick re-pair every span after it, so real code started reading as prose and real prose as code — two false positives in CONTRIBUTING.md and this very file. Spans are now matched per paragraph, which is the actual CommonMark bound (a code span cannot contain a blank line), and doubled delimiters (`task <name>`) and spans that wrap across lines are handled.
Escape hatches, both auditable: docs/.claims-ignore.yaml (entries require a reason) and the inline marker <!-- claims-ignore: TOKEN reason -->. Unused allowlist entries are reported, so an entry covering a planned file prunes itself the moment the file lands. docs/reference/ is skipped for the token checks — it is generated from the truth — but note what that assumption cost: rbac.md faithfully published nine permission rules for routes that do not exist, because "generated from the truth" only holds when the source is itself gated. It now is: tests/api/auth/test_auth.py::test_every_permission_rule_matches_a_real_route asserts the reverse of the long-standing route→rule check, down to the HTTP method (two of the nine were method-level: a POST on a GET-only path, and a PUT that had become PATCH).
Link integrity is the older sibling of claims linting: scripts/check-doc-links.py (task docs:check-links) validates Markdown links and, beyond Python sources, docs/**/*.md path mentions across TypeScript, JSON, YAML, TOML, and Markdown files repo-wide — so a doc rename breaks CI instead of breaking readers.
Pillar 4 — The docs website
Decision: keep VitePress on hegemony-sh/hegemony-sh.github.io; the docs source of truth stays in this repository.
- The site repository holds the landing pages and the VitePress shell (EN + Czech locales, local search) and pulls
docs/from this repository at build time (cross-repo checkout, the same pattern as.github/actions/checkout-siblings;sync-platform-docs.mjsin the site repository does the copy, link adjustment, and sidebar generation, and copiesdocs/assets/so generated screenshots serve from the site itself). Generated reference pages are already committed here, so the site build needs no Python toolchain. - The API reference is built from the committed
apps/api/openapi.json(ReDoc), so it can never drift from the deployed contract. - Rebuilds: on demand (
workflow_dispatch), on push, and on a daily schedule. Arepository_dispatchtrigger from this repo's CI is tracked inTODO.md(requires a cross-repo token). - The Help drawer's external links point at the site root, not deep page URLs; the deep-link upgrade (now that the site's URL scheme is fixed) is tracked in
TODO.md. The drawer deliberately fabricates no page URLs until it lands.
Why not the alternatives:
- MkDocs Material — excellent, but it would discard the existing site, introduce a second (Python) docs toolchain next to the site's npm one, and its flagship capability (rendered Python API reference via mkdocstrings) is one we deliberately do not need: Hegemony is an application, not a library, and half the codebase is TypeScript.
- Docusaurus — MDX migration risk across ~13.5k lines of existing Markdown (
{and<become syntax), heavier builds, no needed capability over VitePress. - Astro Starlight — the best greenfield option, but not enough better than the already-working VitePress site to justify a migration.
VitePress caveats, handled at integration: mermaid renders through vitepress-plugin-mermaid; inline code spans are v-pred so {{ }} in them never reaches Vue; heading anchors use GitHub's slug algorithm so anchors written against GitHub rendering keep working. The {{ }} interpolation hazard in prose is closed on both sides — generated cells escape it (Pillar 1) and hand-written prose is gated against it (Pillar 3).
Language policy: platform documentation is English-only. The Czech locale covers the site's landing/intro pages only. Translated platform docs would double the freshness surface of every gate in this document; that cost is not worth it today.
Naming policy: doc files use lower-kebab-case.md. The eleven legacy SCREAMING-KEBAB.md files were renamed together with every in-repo reference to them; task docs:check-links is what makes that safe to do in one commit. Published URLs are a constraint now that the site ships these paths: renames need VitePress redirects.
Title policy: a page's H1 is its identity — it names the page in the generated index, in the in-app help drawer, and in the site nav. So it names the page's subject, in title case, as a single phrase. tests/docs/test_doc_titles.py enforces that: exactly one H1 and it comes first, no authoring-process words ("FINAL SPEC", "(Improved)", "Draft"), no genre suffix ("... Guide", "... Documentation", "... Specification", "... Feasibility Study", "... Architecture"), no version pin, no subtitle after a dash or colon, no Hegemony prefix inside Hegemony's own docs, and no two pages sharing a title. Status, phase, and revision history go in a note under the title, where they can be updated without renaming the page. All thirteen titles this rule swept out of the tree trip it.
Versioning: latest-only, deployed from the default branch. Revisit trigger: the first time a release/X.Y line needs docs that diverge from develop.
Docs licensing (decided): pages carry the repo's AGPL-3.0-or-later headers, and the published site keeps that license. A more permissive docs license was considered and declined.
Pillar 5 — In-app help
Decision: bundle documentation into the UI (Hegemony deployments may be air-gapped; help must not depend on internet access).
Implemented mechanics:
- The
help_contentemitter compilesdocs/docs-map.yaml(same map, third consumer) into per-page generated Markdown underapps/ui/src/generated/help/plus a thin manifest module (apps/ui/src/generated/help-manifest.ts): route pattern → mapped pages, page → title, and page → a dynamic?rawimport, so Vite code-splits one tiny chunk per page, fetched the first time the drawer shows it. (The first design embedded the whole corpus as string literals in one generated TS module; the chunk grew linearly forever and the file was the top merge-conflict source, so it was redone.) The generated files stay fully offline-capable committed artifacts under the samedocs:verify-syncgate as the reference pages (and is excluded from formatters viaapps/ui/.prettierignore). - A route-aware Help drawer in the app shell (
apps/ui/src/components/help/) always offers an "All topics" index of every bundled page (grouped by docs section, with the live Installed Handlers catalog alongside the reference entries), so the whole corpus is browsable from any screen; routes without mapped docs open straight into it. The drawer renders the mapped pages withreact-markdown+remark-gfm(MIT). The bundled-docs module is lazy-loaded on first open, so it never weighs on the initial bundle, andmermaidfences render as diagrams client-side (mermaid, MIT, its own lazy chunk loaded only when a shown page carries one - nothing is fetched, so air-gap holds). Relative links between bundled pages navigate inside the drawer; other links open on the documentation site. EmptyStateaccepts an optionalhelpHrefso empty screens can teach.- The
HEGEMONY_DOCS_BASE_URLsetting (surfaced through the public/auth/configpayload) points "full documentation" links at hegemony.sh by default, or at an internally hosted copy for air-gapped deployments. Publishing the built site as a release tarball for such hosting is tracked inTODO.md.
Already true today, and worth protecting: pydantic Field(description=...) is in-app help. SchemaForm.tsx renders JSON-Schema descriptions as form help text for every plugin/config surface, and tests/docs/test_settings_descriptions.py keeps Settings descriptions at 100% coverage. Write descriptions as user-facing sentences.
Pillar 8 — Plugin-owned documentation
Decision: plugins own their prose, the runtime surface is the authority, and the committed reference page is scoped to "the wheels this release pins."
Every other generated page compiles from a source of truth inside this repo. The plugin catalog cannot: handlers, inventory providers, notification transports, secret backends, probes and device transports all ship as out-of-tree wheels installed per instance. Gating the fixture (above) makes the page honest about the pinned set; it cannot make it right for a deployment that installs a different set, and it says nothing at all about prose — today a plugin's only documentation channel is one-line schema description strings.
The contract
A plugin ships documentation as files inside its own importable package, found by convention — no manifest, no new entry-point group (the shape that shipped, simplified from an earlier sketch with an index.md and a contract.json):
src/hegemony_steps_netcli/docs/
netcli.execute.md # filename == registered handler id, verbatimOne flat docs/ directory, one Markdown page per registered handler id, included in the wheel as ordinary package data. The producer half lives in hegemony-step-plugins: hegemony_step_sdk.docs.load_docs_bundle is the loader whose strictness is the contract (flat bundles, ids matching ^[a-z0-9][a-z0-9._-]*$, bounded reads — 16 KiB per page, 128 KiB per bundle, strict UTF-8), and that repository's CI gates every wheel: pages 1:1 with registered handlers, structural and renderability rules, and a clean-venv smoke that reads the bundles back from the built wheels.
The registered id is already a persistence contract, so a doc's identity is as stable as the id it names. Machine-readable field docs stay in Field(title=…, description=…) on the config model — the channel Pillar 5 already relies on — and are not duplicated into prose. A handler without a page simply has none (has_docs is false in the catalog and the editor shows no documentation section); the registry one-liner and field help remain the floor every handler already has.
Delivery
- Runtime (authoritative):
packages/core/plugin_docs.pyreads every installed wheel's bundle viaimportlib.resources(its own strict reader, so SDK version skew cannot matter), sanitizes the Markdown server-side, and caches per process.GET /flows/step-handler-types/{handler_id}/docsserves it same-origin to any authenticated user, the catalog'shas_docsflag tells the editor which handlers have a page, and the step editor's Handler tab links to it - the page opens in the Help drawer's Installed Handlers topic (deep-linked through the help bus), where the bundled step-handler reference page also cross-links every documented installed handler. No outbound network at any point, so air-gap holds by construction rather than by policy. - Site (source-direct): the documentation site publishes the plugin pages straight from the plugin repository (
sync-plugin-docs.mjsin the site repository), so plugin prose is never vendored into this repo. The site's copy of the generated step-handler reference is cross-linked to those pages at sync time. The earlier design harvested pinned wheels into a generated page here; it was dropped as duplication once the site could pull the plugin repository directly.
Non-negotiables
- Never bundle plugin prose into the generated help bundle (
apps/ui/src/generated/help/and its manifest). Doing so would ship in-app documentation for wheels an instance does not install, at the same trust level as first-party help. The two surfaces are never merged into one list. - Never path-join a requested doc id.
Traversable.joinpathaccepts.., so the reader enumerates the docs directory once and matches ids exactly (validated against^[a-z0-9][a-z0-9._-]*$). - Third-party markdown is untrusted. Images are stripped server-side and blocked in the renderer: a markdown image with a relative target becomes an authenticated same-origin GET fired from the operator's browser, and one with a remote target is both a beacon and an air-gap violation. Whoever relaxes this later needs to know it is not only about the network hop. Raw HTML is dropped, mermaid becomes the existing placeholder, links are inert unless explicitly allowed, and plugin prose renders in a visibly distinct frame with attribution so it is never mistaken for first-party guidance.
- Plugin prose is never vendored into this repository. The site pulls it from the plugin repository, and the app reads it from installed wheels; each page keeps its own
SPDX-FileCopyrightTextin its home repo. (This also kills the failure mode where generated pages would stamp this repository's copyright over third-party text andreuse lintwould pass — a legal failure no gate would see.) - A bad docs bundle can never fail plugin registration, a catalog request, or worker boot. Reads are isolated and capped (page and per-distribution byte limits, strict UTF-8).
Enforcement, both sides
| Side | Gate |
|---|---|
| Plugin repo | a docs:check task in that repo — registered ids and doc files match 1:1 both ways; SPDX/H1/length/link rules; every config-model property has a description; READMEs derive handler lists from the registry; markdownlint |
| Plugin repo | hegemony-step-plugins/scripts/smoke_install_wheels.py asserts the docs bundle is readable from the installed wheel — the only mechanical proof docs left git and entered the artifact |
| Platform | the step-handler fixture (and the reference page compiled from it) byte-equals a dump of the pinned catalog; sanitizer output is HTML/image/mermaid-free and idempotent; traversal ids rejected; a bad bundle degrades to "no docs" without failing registration (tests/core/test_plugin_docs.py) |
Open decisions
Recorded rather than assumed: whether prose from plugins outside this project's own repositories ever reaches hegemony.sh (recommendation: no — the site syncs only the first-party plugin repository); whether plugin docs may contain diagrams (recommendation: no - first-party help pages now render mermaid client-side, but that renderer parsing plugin-authored text would put an untrusted input in front of a very large parser surface); and whether hegemony.flow_interface joins the shared loader (recommendation: explicitly out until it has a load record and a description field).
Pillar 6 — Screenshots
Every screenshot in docs is generated, never hand-captured:
- The
docs-shotsPlaywright project (apps/ui/e2e/docs-shots/) renders each screen named inmanifest.jsonagainst the same mocked API the E2E suite uses, with a fixed viewport and a frozen clock, writing todocs/assets/screenshots/. Refresh locally withtask docs:screenshots; the project only exists whenDOCS_SHOTS=true, so ordinary E2E runs never produce images. tests/docs/test_screenshots.pyforbids images in docs that are not manifest-generated (both directions: every manifest entry has its PNG, every committed PNG and every embedded image is named by the manifest, and every image carries alt text — the in-app view renders the alt).- Refresh happens via the weekly
ci-heavyrun (plus manual dispatch), which opens a refresh PR when pixels changed — not a per-PR gate, because browser anti-aliasing is not byte-stable across environments and a hard gate would be flaky. The docs-map freshness gate is the per-PR control; it detects the change at the source level.
Pillar 7 — Inline documentation
Conventions are documented in CONTRIBUTING.md (Python: module docstrings + Google-style sections + reST cross-reference roles; TypeScript: file-level JSDoc purpose headers).
Enforcement is presence only, and deliberately incremental to avoid churn:
- Python — enabled. Ruff selects
D100(modules),D101(public classes), andD104(packages), withtests/*exempt: a test's name is its documentation. Turning these on required writing the 144 docstrings that were missing, concentrated in the Configuration Exchange sync engine and the API schemas. D102/D103(methods and functions) — enabled. Turning these on required writing the 296 docstrings that were missing, concentrated in the Configuration Exchange serialization adapters, the flow-interface field types, and the step-handler services.- Never D2xx/D4xx style rules — the established Google + reST hybrid is valid, and mass-reformatting docstrings is churn without a reader benefit.
convention = "google"is set anyway, to record which convention step 2 must follow. - TSX — enabled.
apps/ui/scripts/check-page-docs.mjs(task ui:check:page-docs, following thecheck-contract-types.mjsprecedent) requires every component undersrc/pages/to carry a JSDoc block, either as a file header or attached to an exported declaration - both shapes were already in use and both read fine. Enabling it meant writing 40 headers. Escape: a comment containing@page-docs-exemptplus the reason.
Why the API schemas mattered most of the 144: a pydantic model's docstring becomes its JSON Schema description, which FastAPI publishes in openapi.json, which ReDoc and Swagger UI show to API consumers and openapi-typescript turns into @description JSDoc on the generated TypeScript types. Forty-eight undocumented request and response schemas were forty-eight blank entries in the published API reference.
No rendered code-API reference (mkdocstrings/typedoc) for this repository: it is an application, and contributors read the source. Revisit trigger: the plugin SDK repositories wanting a hosted API reference for plugin authors.
Remaining and deferred work is tracked in TODO.md at the repository root — this document records the design, not live status.
For contributors, in practice
- Changed a UI page, a feature's backend, or anything a doc describes? Update the mapped doc in the same PR (
docs/docs-map.yamltells you which one), or use the override honestly. - Added a route? Add its docs-map entry (or
docs: nonewith a reason) —task testwill remind you. - Changed settings, permissions, enums, maintenance jobs, the handler fixture, or Taskfile descriptions? Run
task docs:generateand commit the result — pre-commit does this for you. - Writing prose?
task docs:verify-claimsandtask docs:check-linkskeep you honest about names and paths. Placeholders go in backticks —<name>outside them is an HTML tag to the renderer, and your sentence loses its ending. - Adding a page?
lower-kebab-case.md, one H1 naming its subject in title case, and an entry indocs/docs-map.yaml. The index picks it up on the nexttask docs:generate. - Adding a module, a public class, a package, or a page component? It needs a docstring or a JSDoc block —
task py:lintandtask ui:check:page-docswill say so. A pydantic schema's docstring is published in the API reference, so write it for the person calling the endpoint. - Everything at once:
task docs:all.