diff --git a/actions/docs-sync/action.yml b/actions/docs-sync/action.yml
new file mode 100644
index 0000000..3c3e803
--- /dev/null
+++ b/actions/docs-sync/action.yml
@@ -0,0 +1,33 @@
+name: Docs sync
+description: >-
+ Verify (or write) the consumer's `.ceremony/` doctrine mirror —
+ byte-identical to heavy-duty/ceremony at the consumer's pinned ref
+ (issue #19). Machinery is consumed by reference; documents have no
+ runtime, so they are vendored, machine-written and machine-verified.
+ The pin is read from the consumer's `.github/workflows/release.yml` —
+ the same single line that pins the release workflow, so one pin governs
+ machinery and doctrine alike. The caller must have checked out its own
+ repository first: the tool reads the consumer's tree at the workspace.
+inputs:
+ mode:
+ description: >-
+ "check" diffs the mirror against the pin and fails on any drift
+ (what CI runs); "fix" writes the mirror to match exactly.
+ required: false
+ default: check
+ source:
+ description: >-
+ Optional local ceremony checkout to mirror from instead of fetching
+ the pinned ref — offline tests, and previewing a ceremony PR against
+ a consumer before release.
+ required: false
+ default: ""
+runs:
+ using: composite
+ steps:
+ - name: docs sync
+ shell: bash
+ env:
+ MODE: ${{ inputs.mode }}
+ SOURCE: ${{ inputs.source }}
+ run: bash "$GITHUB_ACTION_PATH/docs-sync.sh"
diff --git a/actions/docs-sync/docs-sync.sh b/actions/docs-sync/docs-sync.sh
new file mode 100644
index 0000000..f6a3cff
--- /dev/null
+++ b/actions/docs-sync/docs-sync.sh
@@ -0,0 +1,319 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# docs-sync.sh [--check|--fix] [--source
] — materialize and verify the
+# `.ceremony/` doctrine mirror in a governed repo. Run from the consumer's
+# repo root (the action runs after the consumer's own checkout, like every
+# guard).
+#
+# WHY A COPY EXISTS AT ALL (the reference-vs-mirror rationale — issue #19,
+# PR #17's consumption model): workflows and actions are consumed BY
+# REFERENCE — GitHub fetches them from the pinned ref at run time, so no
+# copy ever exists in a consumer, and nothing can drift. Documents have no
+# such runtime. A document's only "runtime" is an agent reading the working
+# tree of the repo it stands in, and a doc that requires a cross-repo fetch
+# before it governs is a doc that sometimes goes unread. So the agent-facing
+# set (the manifest, docs/VENDORED.txt) must exist IN each governed repo's
+# tree. Anyone tempted to "simplify" `.ceremony/` back to a pointer at
+# heavy-duty/ceremony is reinventing the sometimes-unread doc. The mirror is
+# the fix, and this tool is what makes the mirror safe: machine-written
+# (--fix), machine-verified (--check, in the consumer's CI on every PR), so
+# drift is unrepresentable — the only kind of copy the org allows.
+#
+# ONE PIN GOVERNS MACHINERY AND DOCTRINE. The ref is read from the
+# consumer's .github/workflows/release.yml — the single
+# `uses: heavy-duty/ceremony/.github/workflows/release.yml@[` line —
+# never from an input or a second config file: a second pin is a second
+# thing to bump, and two pins can disagree, which is exactly the drift this
+# tool exists to make unrepresentable. Exactly one such line must match;
+# zero or several is a refusal that names the file — this tool never
+# guesses a ref. Commented-out lines do not count: ceremony's own
+# release.yml carries the pin shape inside its header essay, and any
+# consumer that pastes a documentation snippet into a comment would
+# otherwise appear to have two pins.
+#
+# THE MIRROR IS EXACT — manifest ∪ `.ceremony/`, nothing else. A drifted
+# file, a missing file, an extra file not in the manifest, or no
+# `.ceremony/` at all each fails --check with a message naming the offender
+# and the fix; --fix writes AND deletes (a manifest removal must remove the
+# vendored copy — mirror means mirror, or "extra" files accumulate as
+# unverified doctrine).
+#
+# TWO FILES ARE SPECIAL, both deliberately:
+# * `.ceremony/README.md` is GENERATED here — the machine-managed marker
+# plus where the pin lives — instead of per-file banners, so every
+# vendored file stays byte-identical to its source and the check is a
+# plain cmp, never a strip-the-banner parse.
+# * the consumer's ROOT AGENTS.md is scaffolded once by --fix and never
+# overwritten. Agent harnesses auto-load root AGENTS.md (the cross-agent
+# convention), so the stub is what makes "you are a reviewer here" a
+# sufficient launch prompt — but it is per-repo content the moment the
+# repo edits it, so --check asserts only that it exists.
+#
+# A file of its own so test/docs-sync.test.sh can drive both modes against
+# constructed source and consumer trees. --source substitutes a local
+# ceremony checkout for the tarball fetch: offline tests, and previewing a
+# ceremony PR against a consumer before anything is released. The pin is
+# still read and validated with --source — a consumer without its one pin
+# line has nothing for the mirror to be verified against.
+
+WORKFLOW=".github/workflows/release.yml"
+MIRROR=".ceremony"
+MANIFEST="docs/VENDORED.txt"
+README_NAME="README.md"
+
+die() {
+ printf '%s\n' "$@" >&2
+ exit 1
+}
+
+# Inputs arrive as env vars from action.yml (MODE, SOURCE) or as flags for
+# local and test use; flags win.
+mode="${MODE:-check}"
+source_dir="${SOURCE:-}"
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --check) mode=check ;;
+ --fix) mode=fix ;;
+ --source)
+ [ $# -ge 2 ] || die "docs-sync: --source needs a directory"
+ source_dir="$2"
+ shift
+ ;;
+ *) die "docs-sync: unknown argument: $1 (usage: docs-sync.sh [--check|--fix] [--source ])" ;;
+ esac
+ shift
+done
+case "$mode" in
+ check | fix) ;;
+ *) die "docs-sync: unknown mode: '$mode' (check or fix)" ;;
+esac
+
+# --- the pin ----------------------------------------------------------------
+
+[ -f "$WORKFLOW" ] || die \
+ "docs-sync: no $WORKFLOW — the pin lives there (the single" \
+ " 'uses: heavy-duty/ceremony/.github/workflows/release.yml@][' line)." \
+ " Add the release caller before syncing doctrine: the mirror is verified" \
+ " against the pin, and without one there is nothing to verify against."
+
+# A real `uses:` key only — a leading '#' anywhere before it is a comment
+# and does not count (see the header: ceremony's own release.yml carries
+# the shape in a comment).
+mapfile -t pin_lines < <(grep -E \
+ '^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]*heavy-duty/ceremony/\.github/workflows/release\.yml@' \
+ "$WORKFLOW" || true)
+case "${#pin_lines[@]}" in
+ 0)
+ die "docs-sync: no pin line in $WORKFLOW — expected exactly one" \
+ " 'uses: heavy-duty/ceremony/.github/workflows/release.yml@]['," \
+ " found none. This tool never guesses a ref."
+ ;;
+ 1) ;;
+ *)
+ die "docs-sync: ${#pin_lines[@]} pin lines in $WORKFLOW — exactly one" \
+ " 'uses: heavy-duty/ceremony/.github/workflows/release.yml@][' must" \
+ " match, or 'the pin' is ambiguous. This tool never guesses a ref."
+ ;;
+esac
+ref="$(printf '%s\n' "${pin_lines[0]}" | sed -E 's/^.*@//; s/[[:space:]#].*$//')"
+[ -n "$ref" ] || die "docs-sync: the pin line in $WORKFLOW carries an empty ref after '@'"
+
+# --- the source tree ----------------------------------------------------------
+
+fetch_tmp=""
+# An if, not `&&`: the trap's last status becomes the script's exit code,
+# and a bare `[ -n ] && rm` returns 1 whenever there was nothing to clean —
+# turning every --source success into a failure.
+cleanup() { if [ -n "$fetch_tmp" ]; then rm -rf "$fetch_tmp"; fi; }
+trap cleanup EXIT
+
+if [ -n "$source_dir" ]; then
+ [ -d "$source_dir" ] || die "docs-sync: --source: no such directory: $source_dir"
+ src="$source_dir"
+ origin="$source_dir (--source override; pin is heavy-duty/ceremony@$ref)"
+else
+ # The repo is public: a plain tarball fetch, no auth, no git. Works for a
+ # tag, a branch, or a commit SHA alike.
+ fetch_tmp="$(mktemp -d)"
+ url="https://github.com/heavy-duty/ceremony/archive/${ref}.tar.gz"
+ curl -fsSL "$url" | tar -xz --strip-components=1 -C "$fetch_tmp" || die \
+ "docs-sync: cannot fetch heavy-duty/ceremony@$ref ($url) —" \
+ " does the pinned ref exist?"
+ src="$fetch_tmp"
+ origin="heavy-duty/ceremony@$ref"
+fi
+
+# --- the manifest -------------------------------------------------------------
+
+# The manifest is read from the SOURCE tree, not the consumer: what gets
+# mirrored is decided at the pinned ref, so bumping the pin onto a ref that
+# adds or drops a doc re-shapes the mirror in the same PR, with no second
+# list to update. It is also the single source of the set — nothing below
+# hardcodes a filename.
+manifest_file="$src/$MANIFEST"
+[ -f "$manifest_file" ] || die \
+ "docs-sync: no $MANIFEST in $origin — the manifest is the single source" \
+ " of what gets mirrored; a ref that predates it cannot govern a mirror." \
+ " Bump the pin to a ref that carries it."
+mapfile -t manifest < <(grep -v '^[[:space:]]*$' "$manifest_file" || true)
+[ "${#manifest[@]}" -gt 0 ] || die \
+ "docs-sync: $MANIFEST in $origin is empty — an empty doctrine set is a" \
+ " ceremony bug, not a repo with no rules; refusing to mirror it."
+for f in "${manifest[@]}"; do
+ case "$f" in
+ /* | *..*)
+ die "docs-sync: refusing manifest path '$f' — the mirror writes only" \
+ " inside $MIRROR/, and that path escapes it."
+ ;;
+ esac
+ [ -f "$src/$f" ] || die \
+ "docs-sync: the manifest names '$f' but $origin has no such file —" \
+ " fix $MANIFEST in heavy-duty/ceremony."
+done
+
+in_manifest() {
+ local p
+ for p in "${manifest[@]}"; do
+ [ "$p" = "$1" ] && return 0
+ done
+ return 1
+}
+
+# Every file physically present in the mirror, relative to it. sorted so
+# messages come out in a stable order.
+mirror_files() {
+ find "$MIRROR" -type f | LC_ALL=C sort | while IFS= read -r path; do
+ printf '%s\n' "${path#"$MIRROR"/}"
+ done
+}
+
+# --- the two generated texts ---------------------------------------------------
+
+readme_content() {
+ cat <<'EOF'
+# .ceremony/ — the vendored doctrine mirror
+
+Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit
+these files here: they are byte-identical copies of
+[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this
+repository's pinned ref, and CI re-diffs them on every PR — a hand edit
+goes red. They are changed in heavy-duty/ceremony, through its own flow,
+and arrive here when the pin moves.
+
+The pin lives in `.github/workflows/release.yml` — the single
+`uses: heavy-duty/ceremony/.github/workflows/release.yml@][` line. One
+pin governs machinery and doctrine alike: bump it and re-sync this mirror
+in the same PR (`docs-sync --fix`, or let the red check on the bump PR say
+what is stale).
+EOF
+}
+
+stub_content() {
+ cat <<'EOF'
+# AGENTS.md — start at .ceremony/
+
+This repository is governed by
+[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
+`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
+beside it. Repo specifics (the review panel roster, the scope labels, what
+a drill means here, code conventions) live in CONTRIBUTING.md.
+EOF
+}
+
+# --- check ----------------------------------------------------------------------
+
+run_check() {
+ local failures=0 f rel
+ complain() {
+ printf '%s\n' "$@" >&2
+ failures=$((failures + 1))
+ }
+
+ if [ ! -d "$MIRROR" ]; then
+ complain "docs-sync: $MIRROR/ is missing entirely — this tree carries no" \
+ " doctrine mirror. Fix: run docs-sync --fix and commit the result."
+ else
+ for f in "${manifest[@]}"; do
+ if [ ! -f "$MIRROR/$f" ]; then
+ complain "docs-sync: $MIRROR/$f is missing from the mirror." \
+ " Fix: run docs-sync --fix."
+ elif ! cmp -s "$src/$f" "$MIRROR/$f"; then
+ complain "docs-sync: $MIRROR/$f has drifted from $origin." \
+ " Vendored files are never edited in place — they are changed in" \
+ " heavy-duty/ceremony, through its own flow. Fix: run docs-sync --fix" \
+ " (or re-run after bumping the pin, if the drift is a stale pin)."
+ fi
+ done
+ while IFS= read -r rel; do
+ [ "$rel" = "$README_NAME" ] && continue
+ in_manifest "$rel" && continue
+ complain "docs-sync: extra file in the mirror: $MIRROR/$rel — not in the" \
+ " manifest ($MANIFEST). The mirror is exact: everything under" \
+ " $MIRROR/ must be vendored and verified, or it poses as doctrine" \
+ " without being checked. Fix: run docs-sync --fix (it deletes orphans)."
+ done < <(mirror_files)
+ fi
+
+ # Existence only, content free: the stub is per-repo the moment the repo
+ # edits it (see the header).
+ [ -f AGENTS.md ] || complain \
+ "docs-sync: no root AGENTS.md — the stub that routes agents into" \
+ " $MIRROR/ is missing, so 'you are a reviewer here' has no entry point." \
+ " Fix: run docs-sync --fix (scaffolds it once; edit it freely after)."
+
+ [ "$failures" -eq 0 ] || die "docs-sync: $failures problem(s) — see above."
+ echo "docs-sync: $MIRROR/ is an exact mirror of $origin (${#manifest[@]} files)"
+}
+
+# --- fix ------------------------------------------------------------------------
+
+run_fix() {
+ local changed=0 f rel dest
+ note() {
+ printf 'docs-sync: %s\n' "$1"
+ changed=$((changed + 1))
+ }
+
+ mkdir -p "$MIRROR"
+ for f in "${manifest[@]}"; do
+ dest="$MIRROR/$f"
+ if [ ! -f "$dest" ]; then
+ mkdir -p "$(dirname "$dest")"
+ cp "$src/$f" "$dest"
+ note "added $dest"
+ elif ! cmp -s "$src/$f" "$dest"; then
+ cp "$src/$f" "$dest"
+ note "updated $dest"
+ fi
+ done
+
+ while IFS= read -r rel; do
+ [ "$rel" = "$README_NAME" ] && continue
+ in_manifest "$rel" && continue
+ rm "$MIRROR/$rel"
+ note "deleted $MIRROR/$rel (not in the manifest — mirror means mirror)"
+ done < <(mirror_files)
+ find "$MIRROR" -mindepth 1 -type d -empty -delete
+
+ if [ ! -f "$MIRROR/$README_NAME" ] || ! readme_content | cmp -s - "$MIRROR/$README_NAME"; then
+ readme_content >"$MIRROR/$README_NAME"
+ note "wrote $MIRROR/$README_NAME"
+ fi
+
+ if [ ! -e AGENTS.md ]; then
+ stub_content >AGENTS.md
+ note "created the root AGENTS.md stub (scaffolded once, never overwritten)"
+ fi
+
+ if [ "$changed" -eq 0 ]; then
+ echo "docs-sync: nothing to do — $MIRROR/ already mirrors $origin exactly"
+ else
+ echo "docs-sync: $changed change(s); $MIRROR/ now mirrors $origin exactly"
+ fi
+}
+
+case "$mode" in
+ check) run_check ;;
+ fix) run_fix ;;
+esac
diff --git a/docs/VENDORED.txt b/docs/VENDORED.txt
new file mode 100644
index 0000000..10c20a3
--- /dev/null
+++ b/docs/VENDORED.txt
@@ -0,0 +1,5 @@
+AGENTS.md
+TRIAGE.md
+BUILDER.md
+REVIEWER.md
+LABELS.md
]