docs-sync — docs follow code through a native tool, at the PR and at plan close
Mirrored from the fleet knowledge base: wi/components/docs-sync.md
Updated
Mirrored verbatim from
utopia-models/knowledgewi/components/docs-sync.mdby the allowlisted publish pipeline. Edit it there, not here.
component: docs-sync
serves: all
runs-on: github-actions (self-hosted novaserver runners) · every master box (fleet-doctrine Stop hook)
adrs: [9838]
owns:
- wi/docs-map.yaml
- _infra/scripts/docs-map.py
- .github/workflows/docs-sync.yml
- _infra/tests/adr-supersede-chain.sh
- wi/decisions/TEMPLATE.mddocs-sync — docs follow code through a native tool, at the PR and at plan close
WHAT IT IS
One map and two enforcers that ask the same question: did this change touch a path a doc covers, without touching the doc? Built for PLAN-405 P3 (knowledge#9838) to replace hand updates with tools; the decision and the alternatives are ADR-9838.
wi/docs-map.yaml— the one map, every project in one shape (context+maps). Most mappings are not written in it: each component doc's ownowns:list is a mapping to that doc._infra/scripts/docs-map.py— the only reader.check(the map is real),unmet(which docs a change left behind),context(a project's context file).- PR side —
.github/workflows/docs-sync.yml: Claude (anthropics/claude-code-action) commits the doc update to the PR branch — or tomainonce the PR has merged — or posts one✔ docs-synccomment with a reasoned "no doc impact". - Session side — fleet-doctrine
scripts/stop-docs-sync.sh(Stop hook): when a plan closes with an unmet mapping, it blocks once and names the doc. - ADRs —
wi/decisions/TEMPLATE.mdis the MADR 4.0.0 shape; superseding edits the old record'ssuperseded_by:, anddecision-record-linter.shreports a one-way chain.
HOW IT WORKS TODAY
flowchart LR
O["component docs' owns:"] --> R["docs-map.py unmet"]
M["wi/docs-map.yaml maps"] --> R
PR["pull_request opened/synchronize/closed(merged)<br/>or workflow_dispatch pr=N"] --> G{"unmet rows?<br/>(free step, no model)"}
G -- "none, or merged + already recorded" --> X["done — no Claude usage"]
G -- some --> C["claude-code-action<br/>HOME = runner temp"]
C --> E["commit doc to PR branch (open)<br/>or to main (merged)"]
C --> N["one ✔ docs-sync comment:<br/>no doc impact — reason"]
S["Stop, plan closed<br/>(plan-issue loop=closed)"] --> R
R --> B["block once, name the doc"]A mapping is met when ANY of the docs covering a changed path is in the same change. A script
three component docs own needs one of them touched, not all three. A directory owner over
wi/components/ itself is skipped, or every doc edit would demand a second one.
The PR side spends a Claude run only when the free gate finds a left-behind doc. Most dispatched
PRs are merged within seconds of opening, so the opened-event run is still queued at the close, the
pr-run-reaper Worker cancels it, and the head branch is already deleted (#9929). The closed event
is the path those PRs take: its run is created after the close, so the reaper leaves it alone; it reads
the PR's state rather than the event's, checks out main, takes the file list from the PR itself, and
commits any doc update to main before the ✔ docs-sync comment. A merged PR that already carries a
✔ docs-sync comment from its open-side run is skipped. gh workflow run docs-sync.yml -f pr=<n>
runs the same logic for one PR by hand (the backfill). The runner is self-hosted because the org's free
hosted minutes were exhausted (measured in ADR-9838). Pushes and comments use the job's
GITHUB_TOKEN, so the docs commit does not start the workflow again.
The session side reads "the plan closed", never infers it, and scopes to the session's project when
the PLAN-405 state file names one. Its outcome is written to ~/.claude/state/docs-sync/<session>.json
(resolved: updated | no-impact | unresolved).
BOUND VALUES
- ✅
wi/docs-map.yaml - ✅
_infra/scripts/docs-map.py - ✅
.github/workflows/docs-sync.yml - ✅
_infra/tests/adr-supersede-chain.sh - ✅
wi/decisions/TEMPLATE.md - ✅
_infra/scripts/decision-record-linter.sh - ✅
component_owns: wi/components— still live inwi/docs-map.yaml((component_owns: \S+)) - ✅
v1.0.260— still live in.github/workflows/docs-sync.yml(claude-code-action@[0-9a-f]{40} # (v[0-9.]+)) - ✅
0.24.2— still live in.github/workflows/probes.yml(LYCHEE_VERSION: "([0-9.]+)")
DETERMINE CURRENT STATE
python3 _infra/scripts/docs-map.py check # the map is real
git diff --name-only origin/main...HEAD | python3 _infra/scripts/docs-map.py unmet # what a PR left behind
gh run list --repo utopia-models/knowledge --workflow docs-sync.yml --limit 5 # PR-side runs
gh secret list --repo utopia-models/knowledge | grep CLAUDE_CODE_OAUTH_TOKEN # set date of the token
jq -r '.plugins["fleet-doctrine@fleet-doctrine"][0].version' ~/.claude/plugins/installed_plugins.json
ls ~/.claude/state/docs-sync/ # session-side outcomes on this boxKNOWN BROKEN
- ⚠ The session side fires on a state file nobody writes yet.
loop: closedarrives with PLAN-405 P4 (#9839); until then only the step-2 exit marker closes a plan for this hook. - ⚠ The token is one master's subscription.
CLAUDE_CODE_OAUTH_TOKENis aclaude setup-tokentoken of thenova@utopiamodels.ailogin, so PR-side runs draw on the same quota as dispatched work. The secret it replaced (2026-07-14) was revoked and nothing had noticed. - ⚠ A PR merged during the open-side run: the close cancels that run (one concurrency group per
PR) and the closed-event run starts over against
main, so a doc commit the open run had already pushed to the branch only survives if the merge included it.
doc-integrity — the machinery that stops a document outliving what it describes
Mirrored from the fleet knowledge base: wi/components/doc-integrity.md
escalation-gates — a claim that blocks work must be re-derivable, or it is not a gate
Mirrored from the fleet knowledge base: wi/components/escalation-gates.md