diff --git a/.claude/skills/bump-normalization-epoch/SKILL.md b/.claude/skills/bump-normalization-epoch/SKILL.md
new file mode 100644
index 0000000..3c5a027
--- /dev/null
+++ b/.claude/skills/bump-normalization-epoch/SKILL.md
@@ -0,0 +1,91 @@
+---
+name: bump-normalization-epoch
+description: >-
+ Update the content-normalization contract after changing how Confluence
+ storage-format is canonicalized or hashed, so persisted marker hashes don't
+ silently mismatch. Use whenever you edit XmlContentNormalizer,
+ RegexContentNormalizer, the HtmlEntities table, attribute-sort / whitespace /
+ volatile-artifact-stripping rules, the SHA-256 hashing, or switch the active
+ IContentNormalizer — or the moment ContentHasherTests.Golden_* fails. Keeps
+ NormalizationContract.CurrentEpoch and GoldenNormalized in lockstep. Trigger on
+ "нормализатор", "normalization changed", "golden vector failing", "bump epoch",
+ or any diff touching Services/*Normalizer*.cs or the hash recipe.
+---
+
+# Bump the normalization contract (epoch + golden)
+
+## Why this exists
+
+The normalized storage format of each page is hashed (SHA-256) and stored in its
+`.id*` marker (`h` = hash, `ne` = epoch). On the next sync, `ContentHasher`
+recomputes the hash and compares — that's how a *real* local edit is told apart
+from an mtime-only touch (editor re-save, pretty-print, copy, `touch`, VCS
+checkout), eliminating false "changed locally" conflicts.
+
+That only holds while the **recipe is stable**. If you change normalization so
+its output differs, every marker written under the old recipe now hashes
+differently → the tool either cries wolf on untouched pages or misses genuine
+edits. The guard is a two-part contract in
+[`NormalizationContract`](../../../src/ConfluencePageExporter/Services/NormalizationContract.cs):
+
+- **`CurrentEpoch`** — stamped into new markers. A marker whose epoch ≠
+ `CurrentEpoch` is distrusted and the code safely falls back to mtime comparison
+ (see `ContentHasher.HasChanged`). Bumping the epoch is what actually protects
+ old markers — it makes them opt out instead of silently mismatching.
+- **`GoldenNormalized`** — the expected normalization of `GoldenInput`. A runtime
+ self-check + the unit test
+ [`ContentHasherTests.Golden_DefaultNormalizerReproducesContract`](../../../tests/ConfluencePageExporter.Tests/Services/ContentHasherTests.cs)
+ fail the build the instant normalized output drifts. That red test is the
+ intended tripwire that sends you here.
+
+## When to run
+
+Any change that can alter normalized output: `XmlContentNormalizer`,
+`RegexContentNormalizer`, the `HtmlEntities` table, attribute-sort / whitespace /
+volatile-artifact-stripping rules, the hash algorithm, or switching the active
+`IContentNormalizer`.
+
+## Steps
+
+1. **Make your normalizer change first.** Build.
+2. **Run the golden tripwire** — it should now fail:
+ ```
+ dotnet build tests/ConfluencePageExporter.Tests/ConfluencePageExporter.Tests.csproj
+ tests/ConfluencePageExporter.Tests/bin/Debug/net10.0/ConfluencePageExporter.Tests.exe --filter-method "*Golden_DefaultNormalizerReproducesContract*"
+ ```
+ Shouldly prints the **actual** normalized string next to the expected one.
+3. **Refresh `GoldenNormalized`** to the new normalized output. Two ways:
+ - Derive it by hand — apply your new rule to the existing `GoldenNormalized`
+ (usually the cleaner path, since the change is known and local); or
+ - Copy the **actual** string from the failing assertion.
+ ⚠️ `GoldenNormalized` contains a real **U+00A0 non-breaking space** (decoded
+ from ` ` in `GoldenInput`), *not* an ASCII space — preserve it exactly.
+ Console copies of nbsp are error-prone, so prefer deriving by hand and eyeball
+ that single character. Normally you do **not** touch `GoldenInput` — it's the
+ fixed canary; only `GoldenNormalized` changes.
+4. **Increment `CurrentEpoch` by 1.**
+5. **Add a history line** to the `CurrentEpoch` doc-comment, matching the existing
+ style (`History: 1 = initial; 2 = strip ac:macro-id …; 3 = …`), describing what
+ the new epoch changes.
+6. **Re-run `ContentHasherTests` (and the full suite).** Golden passes;
+ `Xml_RegimeTrusted_AndEpochCurrent` confirms the regime is trusted at the new
+ epoch.
+7. **CHANGELOG if user-visible.** An epoch bump that fixes phantom "local changed"
+ diffs *is* user-facing (e.g. epoch 2 shipped in v2.12.1 to fix Confluence
+ canonicalization noise) — add a `### Исправлено`/`### Изменено` entry to
+ `[Unreleased]` in **both** `CHANGELOG.md` and `CHANGELOG.en.md`. A pure internal
+ refactor with byte-identical output shouldn't have needed an epoch bump at all.
+
+## Gotchas
+
+- Bump **both** epoch and golden. The test enforces the golden; only *you* can
+ enforce the epoch bump — and the epoch is the half that actually shields
+ existing markers.
+- Old markers aren't rewritten in place; they self-heal (distrusted → mtime
+ fallback) until the next sync re-stamps them at the new epoch. That's expected.
+- If you *swap* the active normalizer (e.g. to `RegexContentNormalizer`) rather
+ than edit one, the same contract applies — its output must reproduce
+ `GoldenNormalized` or the regime goes untrusted.
+
+See also `.claude/rules/dotnet-maintenance.md` ("КРИТИЧНО: контракт
+нормализации") and the phantom-diff history behind epochs 2–3.
diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md
new file mode 100644
index 0000000..74fcf2d
--- /dev/null
+++ b/.claude/skills/release/SKILL.md
@@ -0,0 +1,96 @@
+---
+name: release
+description: >-
+ Cut and publish a new release of Confluence Page Exporter — bump the version
+ everywhere, roll the CHANGELOG, tag, and let CI build and publish the GitHub
+ Release. Use whenever the user wants to ship/cut/publish a release or bump the
+ version: "зарелизить", "выпустить версию", "сделать релиз 2.19.0", "release
+ this", "ship a new version", or right after a batch of user-facing changes has
+ landed on master and should go out. Handles the full ritual so nothing (version
+ strings, bilingual CHANGELOG, tag) drifts out of sync.
+---
+
+# Release Confluence Page Exporter
+
+## The model (read first)
+
+The **git tag `vX.Y.Z` is the source of truth for the released build** —
+`.github/workflows/release.yml` triggers on `push` of a `v*` tag, extracts the
+version from the tag name, and publishes self-contained single-file binaries for
+`win-x64`, `linux-x64`, `osx-arm64` plus a GitHub Release with auto-generated
+notes. Nothing you build locally is published; the tag drives everything.
+
+Even so, keep every in-repo version string in sync with the tag so the source is
+honest (a README that says `v2.16.0` for a `v2.18.0` tool misleads users). This
+skill bumps all five places and, if they had drifted, this run silently corrects
+them.
+
+**Pushing the tag is irreversible and outward-facing** — it publishes a public
+GitHub Release. Do the commit/PR/merge first; tag only at the very end, after
+confirming the version with the user.
+
+## Preconditions
+
+1. On `master`, clean tree, up to date with origin.
+2. Tests green. Run them (xunit.v3 on MTP — `dotnet test` prints nothing useful):
+ ```
+ dotnet build tests/ConfluencePageExporter.Tests/ConfluencePageExporter.Tests.csproj
+ tests/ConfluencePageExporter.Tests/bin/Debug/net10.0/ConfluencePageExporter.Tests.exe
+ ```
+3. `CHANGELOG.md` has a non-empty `## [Unreleased]` section. If it's empty or
+ missing, there is nothing user-facing to release — stop and ask the user.
+
+## Choose the version
+
+Infer the next semver bump from what's under `[Unreleased]`, then **confirm with
+the user before tagging**:
+
+- new features / commands / flags (`### Добавлено`) → **minor** (`2.18.0 → 2.19.0`)
+- only fixes (`### Исправлено`) → **patch** (`2.18.0 → 2.18.1`)
+- breaking changes → **major**
+
+History is almost all minor bumps with the occasional patch (e.g. `2.13.1`).
+
+## Steps
+
+Let `X.Y.Z` be the new version and `DATE` today's date as `YYYY-MM-DD`.
+
+1. **Branch:** `git checkout -b chore/release-X.Y.Z`
+2. **Edit five files** (all in one commit):
+ - `CHANGELOG.md` — rename the heading `## [Unreleased]` → `## [X.Y.Z] — DATE`.
+ - `CHANGELOG.en.md` — same rename, mirroring the existing entry format exactly
+ (same `## [X.Y.Z] — DATE` shape). Per the bilingual rule both CHANGELOGs move
+ together.
+ - `Directory.Build.props` — `OLD` → `X.Y.Z`.
+ - `README.md` — first line `# Confluence Page Sync vOLD` → `vX.Y.Z`.
+ - `README.en.md` — first line, same bump.
+3. **Commit** (message in Russian, per project convention):
+ ```
+ chore(release): версия X.Y.Z
+
+ <2–4 line summary of the release highlights, taken from the CHANGELOG entry>
+
+ Co-Authored-By: Claude Opus 4.8
+ ```
+4. **PR + self-merge** (the user's standing workflow — don't wait for review):
+ `git push -u origin chore/release-X.Y.Z`, `gh pr create --base master ...`
+ (Russian title/body), then `gh pr merge --merge --delete-branch`.
+5. **Sync master:** `git checkout master && git pull --ff-only`.
+6. **Tag on the merge commit and push — this publishes the release:**
+ ```
+ git tag vX.Y.Z
+ git push origin vX.Y.Z
+ ```
+7. **Verify CI:** `gh run watch` (or `gh run list --workflow=Release`) until green,
+ then `gh release view vX.Y.Z` to confirm the three archives attached.
+
+## Notes / gotchas
+
+- **Date must be real** — use today's actual date, never a placeholder.
+- **osx-arm64 ships uncompressed** on purpose (`EnableCompressionInSingleFile=false`
+ in `release.yml`) — a single-file/ARM extraction bug. Don't "fix" it.
+- No CHANGELOG entry is needed *for the release commit itself* — rolling the
+ section IS the change.
+- If a release build fails after the tag is pushed, delete the tag
+ (`git push --delete origin vX.Y.Z`), fix, and re-tag — the Release is only
+ created after all three builds succeed (`fail_on_unmatched_files: true`).
diff --git a/.gitignore b/.gitignore
index ad86652..634a97e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -107,8 +107,9 @@ _ReSharper*/
# AI assistants
############################
.cursor/
-# .claude/ is local by default; the shared agent rules under .claude/rules/ are committed
+# .claude/ is local by default; shared agent rules and project skills are committed
.claude/*
!.claude/rules/
+!.claude/skills/
# CLAUDE.md / AGENTS.md are committed project AI-agent instructions
graphify-out/
diff --git a/src/ConfluencePageExporter/Services/NormalizationContract.cs b/src/ConfluencePageExporter/Services/NormalizationContract.cs
index 8e2fc4e..895fe26 100644
--- a/src/ConfluencePageExporter/Services/NormalizationContract.cs
+++ b/src/ConfluencePageExporter/Services/NormalizationContract.cs
@@ -17,7 +17,8 @@ namespace ConfluencePageExporter.Services;
/// algorithm, or switching the active IContentNormalizer. Stored hashes
/// computed under the old recipe will otherwise silently mismatch. The
/// golden-vector unit test fails until you update both, which is the intended
-/// tripwire. See .cursor/rules/confluence-import-export.mdc §11.
+/// tripwire. See .claude/rules/dotnet-maintenance.md, section
+/// "КРИТИЧНО: контракт нормализации".
///
public static class NormalizationContract
{
diff --git a/src/ConfluencePageExporter/Services/XmlContentNormalizer.cs b/src/ConfluencePageExporter/Services/XmlContentNormalizer.cs
index 59b1e29..121e753 100644
--- a/src/ConfluencePageExporter/Services/XmlContentNormalizer.cs
+++ b/src/ConfluencePageExporter/Services/XmlContentNormalizer.cs
@@ -17,7 +17,8 @@ namespace ConfluencePageExporter.Services;
/// behaviour (whitespace / attribute / entity handling), you MUST bump
/// and refresh its golden value,
/// or stored hashes silently mismatch. The golden-vector test fails until you do.
-/// See .cursor/rules/confluence-import-export.mdc §11.
+/// See .claude/rules/dotnet-maintenance.md ("КРИТИЧНО: контракт нормализации").
+/// The /bump-normalization-epoch skill walks through this update.
///
public sealed class XmlContentNormalizer : IContentNormalizer
{