Skip to content

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:

  1. Inline code documentation — docstrings, JSDoc, and schema descriptions.
  2. The public docs website — usage and architecture docs on hegemony.sh.
  3. In-platform help — documentation reachable inside the running UI.
  4. 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.yaml maps 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:

PageSource of truth
reference/environment-variables.mdpackages/core/settings.py (Settings model fields and descriptions)
reference/rbac.mdapps/api/auth/actions.yaml and apps/api/auth/routes.yaml via the loader in apps/api/auth/permissions.py
reference/vocabulary.mdpackages/core/enums.py
reference/maintenance-jobs.mdThe maintenance job registry in apps/api/services/maintenance/__init__.py
reference/step-handlers.mdapps/ui/e2e/fixtures/step-handler-types.json (the committed demo/e2e handler contract)
reference/commands.mddesc: fields in Taskfile.yml and deploy/compose/Taskfile.yml
index.mdThe 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 the python-checks job (its docs-integrity steps).
  • tests/docs/test_reference_sync.py — renders each emitter in-process and asserts equality with the committed file, so plain task test catches 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 as sh -c with 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 read Use {{ 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 EDIT banner 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:

GateWhat it proves
task docs:verify-syncthe committed page equals a fresh render of the fixture
task docs:verify-fixturesthe committed fixture equals a live dump of the installed wheels
tests/docs/test_step_handler_fixture_sync.pythe 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 in apps/ui/src/App.tsx (keys literally match the path: values). Each entry names the page component, the doc page(s) covering it, and optional extra watch: globs. Routes that genuinely need no docs declare docs: none with a mandatory reason: — 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 by docs:verify-sync, which is strictly stronger).
  • unmapped_ok: — cross-cutting docs (glossary, this document, …); each entry names a path and a mandatory reason, keeping this escape hatch as auditable as docs: none.

Three consumers enforce and use the map:

  1. Completeness meta-test (tests/docs/test_docs_map.py): route set in App.tsx equals the map's route set; all referenced files exist; all watch globs match at least one tracked file; every docs/**/*.md is accounted for. Adding a UI route without a map entry fails task test.
  2. Freshness gate (scripts/check-docs-freshness.py, the docs-freshness step of the python-checks CI 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 the watch glob, or override.
  3. 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-needed label 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 touching docs/, the adversarial pass over exactly the changed prose, plus an audit of any docs-not-needed override 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 — a Settings field 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 in apps/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 in TODO.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 against git 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 as hegemony-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 gap check_handler_ids cannot cover: it only inspects dotted spans whose namespace is still registered, so when connectivity_monitor.* became monitor.* 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.mjs in the site repository does the copy, link adjustment, and sidebar generation, and copies docs/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. A repository_dispatch trigger from this repo's CI is tracked in TODO.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_content emitter compiles docs/docs-map.yaml (same map, third consumer) into per-page generated Markdown under apps/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 ?raw import, 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 same docs:verify-sync gate as the reference pages (and is excluded from formatters via apps/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 with react-markdown + remark-gfm (MIT). The bundled-docs module is lazy-loaded on first open, so it never weighs on the initial bundle, and mermaid fences 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.
  • EmptyState accepts an optional helpHref so empty screens can teach.
  • The HEGEMONY_DOCS_BASE_URL setting (surfaced through the public /auth/config payload) 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 in TODO.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):

text
src/hegemony_steps_netcli/docs/
  netcli.execute.md             # filename == registered handler id, verbatim

One 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.py reads every installed wheel's bundle via importlib.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}/docs serves it same-origin to any authenticated user, the catalog's has_docs flag 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.mjs in 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.joinpath accepts .., 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-FileCopyrightText in its home repo. (This also kills the failure mode where generated pages would stamp this repository's copyright over third-party text and reuse lint would 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 ​

SideGate
Plugin repoa 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 repohegemony-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
Platformthe 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-shots Playwright project (apps/ui/e2e/docs-shots/) renders each screen named in manifest.json against the same mocked API the E2E suite uses, with a fixed viewport and a frozen clock, writing to docs/assets/screenshots/. Refresh locally with task docs:screenshots; the project only exists when DOCS_SHOTS=true, so ordinary E2E runs never produce images.
  • tests/docs/test_screenshots.py forbids 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-heavy run (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:

  1. Python — enabled. Ruff selects D100 (modules), D101 (public classes), and D104 (packages), with tests/* 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.
  2. 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.
  3. 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.
  4. TSX — enabled. apps/ui/scripts/check-page-docs.mjs (task ui:check:page-docs, following the check-contract-types.mjs precedent) requires every component under src/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-exempt plus 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.yaml tells you which one), or use the override honestly.
  • Added a route? Add its docs-map entry (or docs: none with a reason) — task test will remind you.
  • Changed settings, permissions, enums, maintenance jobs, the handler fixture, or Taskfile descriptions? Run task docs:generate and commit the result — pre-commit does this for you.
  • Writing prose? task docs:verify-claims and task docs:check-links keep 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 in docs/docs-map.yaml. The index picks it up on the next task docs:generate.
  • Adding a module, a public class, a package, or a page component? It needs a docstring or a JSDoc block — task py:lint and task ui:check:page-docs will 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.

Released as open source under the AGPL-3.0-or-later license. Development is sponsored by Rexonix s.r.o.. Contact — [email protected].