diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index daacff8..5162bf8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,3 +23,5 @@ jobs:
run: node scripts/check-catalog-sync.cjs
- name: Deploy-home SSOT drift-guard
run: node scripts/check-deploy-home.cjs
+ - name: Localized README bold-link roster drift-guard (warn-only)
+ run: node scripts/check-readme-sync.cjs
diff --git a/CLAUDE.md b/CLAUDE.md
index c5257b1..de9fa19 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -83,7 +83,7 @@ Two canonical docs, two registers: **[CLAUDE.md](./CLAUDE.md) is LLM + human dua
Before committing, confirm:
1. **SKILL.md** follows the [anatomy](#skillmd-format).
-2. **[README](./README.md)** and its localized copies under [`docs/readme/`](./docs/readme/) list it — perspective group, invocation column, linked, in the perspective's logical order ([Layout](#layout)); the root README is the English source, and every translation keeps the `Read in: …` switcher with links rebased from its own directory. That order holds identically across `plugin.json`, [`scripts/catalog.cjs`](./scripts/catalog.cjs), and `re0-upgrade`'s catalog — a new skill or rename lands in place across all four surfaces with `reorder` (`check-catalog-sync` guards the set, not the order).
+2. **[README](./README.md)** and its localized copies under [`docs/readme/`](./docs/readme/) list it — perspective group, invocation column, linked, in the perspective's logical order ([Layout](#layout)); the root README is the English source, and every translation keeps the `Read in: …` switcher with links rebased from its own directory. That order holds identically across `plugin.json`, [`scripts/catalog.cjs`](./scripts/catalog.cjs), and `re0-upgrade`'s catalog — a new skill or rename lands in place across all four surfaces with `reorder` (`check-catalog-sync` guards the set, not the order). Localized READMEs are downstream mirrors of the English source, and [`check-readme-sync`](./scripts/check-readme-sync.cjs) warns in CI when a translation's bold-link roster drifts from English on set or order — warn-only by default so a translation lag never blocks a merge; pass `--strict` to promote it to a failure.
3. **[plugin.json](./.claude-plugin/plugin.json)** registers its path.
4. **[package.json](./package.json)** bumps by **kind, not size**: against the artifact's own prior spec, was the old behavior *wrong* (a fix → patch, even with much new plumbing) or *correct but narrower* (a new capability a user reaches for → minor)? A skill removed with no replacement is major. State that answer before naming the bump; keep `keywords` grouped.
5. A **rename** appends its old → new row to [`re0-upgrade`](./skills/breadth/re0-upgrade/SKILL.md)'s deprecations, in release order.
diff --git a/scripts/check-readme-sync.cjs b/scripts/check-readme-sync.cjs
new file mode 100755
index 0000000..a0907d3
--- /dev/null
+++ b/scripts/check-readme-sync.cjs
@@ -0,0 +1,105 @@
+#!/usr/bin/env node
+'use strict';
+/*
+ * README bold-link roster drift-guard (EN vs localized).
+ *
+ * The English README.md at repo root is the source of truth for the skill roster's presentation
+ * to human readers. The 10 localized copies under docs/readme/README..md are hand-maintained
+ * translations that snapshotted at ba9ae6e and have received no content updates since — every
+ * skill added or renamed on the English side needs to be mirrored, and CLAUDE.md's shipping
+ * checklist (step 2) already requires it. This check enforces the mechanical half of that rule:
+ * each translation must list the same **[](path) bold-links as English, in the same order.
+ *
+ * Scope — intentionally narrow. This script verifies only the bold-link skill SET and ORDER; it
+ * does not judge translated prose, tone, or completeness. The "not the order" carve-out from
+ * check-catalog-sync applies to the four internal roster surfaces (plugin.json / catalog.cjs /
+ * re0-upgrade / EN README) which the maintainer holds by hand; localized READMEs are outside
+ * that boundary because they are downstream mirrors of the EN README, not peers of it.
+ *
+ * Default is warn-only (exit 0) so a translation lag never blocks CI. Pass --strict to fail on
+ * drift once the maintainer wants to promote enforcement.
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const EN_PATH = path.join(ROOT, 'README.md');
+const L10N_DIR = path.join(ROOT, 'docs', 'readme');
+const STRICT = process.argv.includes('--strict');
+
+// Bold-link skill token: **[name](path) — name is lowercase-alnum with hyphens.
+// The path group anchors it to a real Markdown link so plain **[foo]** emphasis
+// (no link target) is ignored. Fenced code blocks are stripped first to avoid
+// matching example snippets that document the syntax.
+const SKILL_RE = /\*\*\[([a-z][a-z0-9-]*)\]\([^)]+\)/g;
+
+function stripFences(src) {
+ const lines = src.split('\n');
+ let inFence = false;
+ const out = [];
+ for (const line of lines) {
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
+ if (!inFence) out.push(line);
+ }
+ return out.join('\n');
+}
+
+function extract(file) {
+ const src = stripFences(fs.readFileSync(file, 'utf8'));
+ const seen = new Set();
+ const order = [];
+ for (const m of src.matchAll(SKILL_RE)) {
+ const name = m[1];
+ if (seen.has(name)) continue; // dedupe: switcher/footer relinks would double-count
+ seen.add(name);
+ order.push(name);
+ }
+ return order;
+}
+
+if (!fs.existsSync(EN_PATH)) {
+ console.error('::error::check-readme-sync: README.md not found at repo root');
+ process.exit(1);
+}
+if (!fs.existsSync(L10N_DIR)) {
+ console.error('::error::check-readme-sync: docs/readme/ not found');
+ process.exit(1);
+}
+
+const en = extract(EN_PATH);
+const localized = fs.readdirSync(L10N_DIR)
+ .filter((f) => /^README\.[a-zA-Z-]+\.md$/.test(f))
+ .sort();
+
+let anyDrift = false;
+const report = [];
+
+for (const f of localized) {
+ const local = extract(path.join(L10N_DIR, f));
+ const localSet = new Set(local);
+ const missing = en.filter((s) => !localSet.has(s));
+ const extras = local.filter((s) => !en.includes(s));
+ const enSubset = en.filter((s) => localSet.has(s));
+ const orderMismatch = enSubset.join(',') !== local.filter((s) => en.includes(s)).join(',');
+
+ if (missing.length || extras.length || orderMismatch) {
+ anyDrift = true;
+ const parts = [];
+ if (missing.length) parts.push(`${missing.length} missing (${missing.join(', ')})`);
+ if (extras.length) parts.push(`${extras.length} unknown (${extras.join(', ')})`);
+ if (orderMismatch) parts.push('order mismatch');
+ report.push(`${f}: ${parts.join('; ')}`);
+ }
+}
+
+const level = STRICT ? 'error' : 'warning';
+if (anyDrift) {
+ console.error(`::${level}::README bold-link roster drift across ${localized.length} localizations (EN has ${en.length} skills)`);
+ for (const line of report) console.error(` ${line}`);
+ if (STRICT) process.exit(1);
+ console.error(' (warn-only; pass --strict to fail CI)');
+ process.exit(0);
+}
+
+console.log(`README sync OK: ${en.length} bold-link skills match across ${localized.length} localizations`);
diff --git a/tests/check-readme-sync.test.cjs b/tests/check-readme-sync.test.cjs
new file mode 100644
index 0000000..c2d9fb2
--- /dev/null
+++ b/tests/check-readme-sync.test.cjs
@@ -0,0 +1,116 @@
+#!/usr/bin/env node
+'use strict';
+/*
+ * Integration tests for scripts/check-readme-sync.cjs.
+ *
+ * Uses a temp scratch repo with a synthetic README.md + docs/readme/*.md so the assertions
+ * pin the script's behavior without depending on the real paperthin state (which happens to
+ * be in sync at the time of writing). Four cases: normal, missing, order-swap, --strict.
+ */
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const SCRIPT = path.join(__dirname, '..', 'scripts', 'check-readme-sync.cjs');
+
+function mkRepo() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'paperthin-sync-'));
+ fs.mkdirSync(path.join(dir, 'scripts'));
+ fs.mkdirSync(path.join(dir, 'docs', 'readme'), { recursive: true });
+ fs.copyFileSync(SCRIPT, path.join(dir, 'scripts', 'check-readme-sync.cjs'));
+ return dir;
+}
+
+function boldLink(name) {
+ return `**[${name}](./skills/${name}/SKILL.md)**`;
+}
+
+function readmeWith(names, opts = {}) {
+ const rel = opts.rel || '.';
+ const lines = ['# Test README', ''];
+ for (const n of names) {
+ const link = `**[${n}](${rel}/skills/${n}/SKILL.md)**`;
+ lines.push(`| ${link} | desc | scope | invoker |`);
+ }
+ return lines.join('\n') + '\n';
+}
+
+function run(repo, args = []) {
+ return spawnSync('node', [path.join(repo, 'scripts', 'check-readme-sync.cjs'), ...args], {
+ cwd: repo,
+ encoding: 'utf8',
+ });
+}
+
+const cases = [];
+
+// Case 1 — normal (all translations match EN)
+cases.push(['normal', () => {
+ const dir = mkRepo();
+ const skills = ['re0', 'readchk', 'aim'];
+ fs.writeFileSync(path.join(dir, 'README.md'), readmeWith(skills));
+ for (const lang of ['ko', 'ja', 'de']) {
+ fs.writeFileSync(
+ path.join(dir, 'docs', 'readme', `README.${lang}.md`),
+ readmeWith(skills, { rel: '../..' })
+ );
+ }
+ const r = run(dir);
+ return r.status === 0 && /README sync OK: 3/.test(r.stdout);
+}]);
+
+// Case 2 — one translation missing a skill (warn-only, exit 0)
+cases.push(['missing', () => {
+ const dir = mkRepo();
+ fs.writeFileSync(path.join(dir, 'README.md'), readmeWith(['re0', 'readchk', 'aim']));
+ fs.writeFileSync(
+ path.join(dir, 'docs', 'readme', 'README.ko.md'),
+ readmeWith(['re0', 'aim'], { rel: '../..' }) // missing readchk
+ );
+ const r = run(dir);
+ return r.status === 0
+ && /1 missing \(readchk\)/.test(r.stderr)
+ && /warn-only/.test(r.stderr);
+}]);
+
+// Case 3 — order mismatch (two skills swapped)
+cases.push(['order-swap', () => {
+ const dir = mkRepo();
+ fs.writeFileSync(path.join(dir, 'README.md'), readmeWith(['re0', 'readchk', 'aim']));
+ fs.writeFileSync(
+ path.join(dir, 'docs', 'readme', 'README.ko.md'),
+ readmeWith(['re0', 'aim', 'readchk'], { rel: '../..' }) // swapped
+ );
+ const r = run(dir);
+ return r.status === 0 && /order mismatch/.test(r.stderr);
+}]);
+
+// Case 4 — --strict fails on drift with exit 1
+cases.push(['strict-fails', () => {
+ const dir = mkRepo();
+ fs.writeFileSync(path.join(dir, 'README.md'), readmeWith(['re0', 'readchk', 'aim']));
+ fs.writeFileSync(
+ path.join(dir, 'docs', 'readme', 'README.ko.md'),
+ readmeWith(['re0', 'aim'], { rel: '../..' })
+ );
+ const r = run(dir, ['--strict']);
+ return r.status === 1 && /::error::/.test(r.stderr);
+}]);
+
+let pass = 0;
+let fail = 0;
+for (const [name, fn] of cases) {
+ try {
+ const ok = fn();
+ if (ok) { pass++; console.log(` ok ${name}`); }
+ else { fail++; console.error(` FAIL ${name}`); }
+ } catch (e) {
+ fail++;
+ console.error(` FAIL ${name}: ${e.message}`);
+ }
+}
+
+console.log(`\n${pass}/${cases.length} passed`);
+process.exit(fail ? 1 : 0);