Explorar el Código

docs: complete 6.2.0 release notes

lingfengQAQ hace 2 semanas
padre
commit
c093f8e72a

+ 10 - 7
.github/workflows/plugin-release.yml

@@ -4,11 +4,7 @@ on:
   workflow_dispatch:
     inputs:
       version:
-        description: 'Plugin version to release, for example 5.5.4'
-        required: true
-        type: string
-      release_notes:
-        description: 'Release notes used in GitHub Release'
+        description: 'Plugin version to release, for example 6.2.0'
         required: true
         type: string
 
@@ -20,7 +16,6 @@ jobs:
     runs-on: ubuntu-latest
     env:
       RELEASE_VERSION: ${{ inputs.version }}
-      RELEASE_NOTES: ${{ inputs.release_notes }}
     steps:
       - name: Checkout
         uses: actions/checkout@v4
@@ -38,6 +33,14 @@ jobs:
             --check \
             --expected-version "$RELEASE_VERSION"
 
+      - name: Validate release notes
+        run: |
+          python webnovel-writer/scripts/validate_release_notes.py \
+            --version "$RELEASE_VERSION"
+
+      - name: Validate plugin package
+        run: python webnovel-writer/scripts/validate_plugin_package.py
+
       - name: Create and push tag
         run: |
           if git rev-parse "v$RELEASE_VERSION" >/dev/null 2>&1; then
@@ -53,4 +56,4 @@ jobs:
         with:
           tag_name: v${{ inputs.version }}
           name: v${{ inputs.version }}
-          body: ${{ inputs.release_notes }}
+          body_path: releases/v${{ inputs.version }}.md

+ 11 - 0
.github/workflows/plugin-version.yml

@@ -6,14 +6,20 @@ on:
       - '.claude-plugin/marketplace.json'
       - 'webnovel-writer/.claude-plugin/plugin.json'
       - 'webnovel-writer/scripts/sync_plugin_version.py'
+      - 'webnovel-writer/scripts/validate_release_notes.py'
       - 'README.md'
+      - 'CHANGELOG.md'
+      - 'releases/**'
       - '.github/workflows/plugin-version.yml'
   pull_request:
     paths:
       - '.claude-plugin/marketplace.json'
       - 'webnovel-writer/.claude-plugin/plugin.json'
       - 'webnovel-writer/scripts/sync_plugin_version.py'
+      - 'webnovel-writer/scripts/validate_release_notes.py'
       - 'README.md'
+      - 'CHANGELOG.md'
+      - 'releases/**'
       - '.github/workflows/plugin-version.yml'
 
 jobs:
@@ -22,6 +28,8 @@ jobs:
     steps:
       - name: Checkout
         uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
 
       - name: Setup Python
         uses: actions/setup-python@v5
@@ -30,3 +38,6 @@ jobs:
 
       - name: Check release metadata consistency
         run: python webnovel-writer/scripts/sync_plugin_version.py --check
+
+      - name: Check release notes
+        run: python webnovel-writer/scripts/validate_release_notes.py

+ 46 - 0
CHANGELOG.md

@@ -0,0 +1,46 @@
+# 更新日志
+
+这里记录每个正式版本对作者和维护者的影响。发布说明优先面向中文网文作者:先说写作体验有什么变化,再补维护者关心的技术细节。
+
+## v6.2.0 - 写章结果更清楚,失败后更好恢复
+
+发版范围:`v6.1.0..v6.2.0`。
+
+### 给作者看的变化
+
+- 写章、审查、规划和初始化结束后,最终报告更像写作助手的汇报:会说明已完成、部分完成、需要你处理或未完成。
+- `/webnovel-write` 中断后,重复执行同一章会优先检查可信断点,尽量从失败位置继续,减少重写和误覆盖。
+- 写章过程减少技术细节打扰;只有创作方向、事实取舍、文件覆盖风险或阻断问题需要裁决时才询问。
+- 写作流程的上下文读取更克制,初始化、规划、写章、审查、查询等命令更聚焦,减少无关资料塞满上下文。
+- 章节提交前后的中间结果校验更稳,能更早发现缺失的审查、事实提取或故事资料同步结果。
+- 文档补充了最终报告读法、恢复边界、日志用途和常见运维入口。
+
+### 是否需要改旧项目
+
+不需要。已有书项目可以继续使用,不需要迁移 `.story-system/` 或 `.webnovel/` 数据。
+
+### 给维护者
+
+- 新增作者术语表、异常目录、审查作者视图、最终报告 helper、写章 run ledger、脱敏 run log。
+- 新增 `user-report`、`run-ledger`、`run-log` 统一 CLI 子命令。
+- 收紧 commit artifacts、projection writers、write-gate 和 postcommit 的结构化校验。
+- 轻量化多个 Skill / Agent 的提示词,补充 reference loading map 和 region-read 规则。
+- 增加 prompt integrity、unit tests、behavior eval,覆盖 artifact ownership、最小写章模式、projection retry、blocking review、断点续跑和日志脱敏。
+
+### 验证
+
+- 相关 pytest 通过。
+- behavior eval 通过。
+- `compileall` 通过。
+- `git diff --check` 通过。
+- 版本同步和插件包校验通过。
+
+## v6.1.0 - 项目体检更稳,出问题更容易定位
+
+- 增加 doctor、project-status、write-gate、projection 重放、hooks、行为评估和插件包校验。
+- 强化 Story System 运行时健康检查和 Marketplace 发布校验。
+
+## v6.0.0 - Story System 主链上线,长篇事实更不容易写乱
+
+- 上线合同种子、运行时合同、章节提交、事件审计和投影链路。
+- 补齐主链相关集成测试。

+ 2 - 2
README.md

@@ -1,7 +1,7 @@
 # Webnovel Writer
 
 [![License](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](LICENSE)
-[![Version](https://img.shields.io/badge/version-6.0.0-brightgreen.svg)](.claude-plugin/marketplace.json)
+[![Version](https://img.shields.io/badge/version-6.2.0-brightgreen.svg)](.claude-plugin/marketplace.json)
 [![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/)
 [![Claude Code](https://img.shields.io/badge/Claude%20Code-Compatible-purple.svg)](https://claude.ai/claude-code)
 [![Marketplace](https://img.shields.io/badge/Claude%20Code-Marketplace-black.svg)](.claude-plugin/marketplace.json)
@@ -313,7 +313,7 @@ Webnovel Writer 用业余时间维护。如果它帮你省下了梳理设定、
 
 | 版本 | 主要变化 |
 |------|----------|
-| **v6.2.0 (当前)** | 作者友好报告与断点续跑 |
+| **v6.2.0 (当前)** | 写章结果更清楚,失败后更好恢复 |
 | **v6.1.0** | 插件运行时加固:新增 doctor/project-status/write-gate/projection 重放、hooks、行为 eval 与发布校验 |
 | **v6.0.0** | Story System 全链路上线(合同种子 + 运行时合同 + 章节提交 + 事件审计),补齐集成测试 |
 | **v5.5.5** | 长期记忆闭环:写前注入 + 写后沉淀,新增 `memory` 运维命令 |

+ 69 - 20
docs/operations/plugin-release.md

@@ -1,48 +1,97 @@
 # 插件发版指南
 
+本项目的发布说明优先面向中文网文作者:先说明这版对写书有什么帮助,再补维护者关心的 CLI、schema、测试和 CI 细节。
+
+## 发版前审计
+
+发布说明必须覆盖“上一个正式版本 tag 到本次发布提交”的全部变化,而不是只写最后一次提交。
+
+发版前先确认版本边界:
+
+```bash
+git tag --list "v*" --sort=-v:refname
+git log --oneline v上一版本..HEAD
+git diff --stat v上一版本..HEAD
+```
+
+把变化分成四类:
+
+- 给作者看的变化:写章、审查、规划、查询、恢复、文档等用户能感受到的变化。
+- 兼容性:是否需要迁移旧书项目,是否改变现有 `/webnovel-*` 命令习惯。
+- 已知影响:跳过项、限制、需要注意的风险。
+- 给维护者:新增 CLI、schema、helper、测试、CI、内部重构。
+
+## 发布说明来源
+
+每个正式版本都必须有两份文档:
+
+- `CHANGELOG.md`:长期更新日志。
+- `releases/vX.Y.Z.md`:GitHub Release 正文的唯一来源。
+
+README 只保留一句中文用户收益摘要,例如:
+
+```md
+| **v6.2.0 (当前)** | 写章结果更清楚,失败后更好恢复 |
+```
+
+不要把 README 当完整 changelog。
+
 ## 版本同步
 
-发版前,先在本地同步 `plugin.json`、`marketplace.json` 和 `README.md` 中的版本号:
+写好 `CHANGELOG.md` 和 `releases/vX.Y.Z.md` 后,再同步版本号和 README 摘要
 
 ```bash
-python -X utf8 webnovel-writer/scripts/sync_plugin_version.py --version X.Y.Z --release-notes "本次版本说明"
+python -X utf8 webnovel-writer/scripts/sync_plugin_version.py --version X.Y.Z --release-notes "一句中文用户收益"
 ```
 
-该命令会自动更新以下文件中的版本信息:
+该命令会更新:
 
 - `webnovel-writer/.claude-plugin/plugin.json`
 - `.claude-plugin/marketplace.json`
-- `README.md` 中的版本标记
+- `README.md` 版本徽章
+- `README.md` 当前版本行
 
-## 通过 GitHub Actions 发版
-
-推荐使用 `Plugin Release` 工作流统一发版:
+## 本地校验
 
-1. 在本地执行版本同步(见上方命令)
-2. 运行插件包校验:
+提交前至少运行:
 
 ```bash
+python -X utf8 webnovel-writer/scripts/sync_plugin_version.py --check --expected-version X.Y.Z
+python -X utf8 webnovel-writer/scripts/validate_release_notes.py --version X.Y.Z
 python -X utf8 webnovel-writer/scripts/validate_plugin_package.py
+git diff --check
 ```
 
-3. 提交并推送版本变更
-4. 打开仓库 Actions 页面,选择 `Plugin Release`
-5. 输入 `version`(如 `6.0.0`)和 `release_notes`
-6. 工作流自动执行:
-   - 校验 `plugin.json`、`marketplace.json` 与 README 版本一致
-   - 校验输入版本与仓库元数据一致
-   - 创建并推送 `vX.Y.Z` Tag
-   - 创建 GitHub Release
+涉及代码或提示词变化时,还要运行对应 pytest、行为评估或 smoke test,并把结果写进 `releases/vX.Y.Z.md` 的“验证”小节。
 
-`validate_plugin_package.py` 会复用 `sync_plugin_version.py` 的 README 当前版本解析规则,避免本地校验和现有 `Plugin Version Check` 工作流产生两套互相冲突的版本规则。
+## 通过 GitHub Actions 发版
+
+1. 确认本地校验通过。
+2. 提交并推送版本说明和版本元数据。
+3. 打开仓库 Actions 页面,选择 `Plugin Release`。
+4. 输入 `version`,例如 `6.2.0`。
+5. 工作流会自动:
+   - 校验 `plugin.json`、`marketplace.json`、README 版本一致。
+   - 校验 `CHANGELOG.md` 和 `releases/vX.Y.Z.md` 存在且覆盖上个 tag。
+   - 校验插件包结构。
+   - 创建并推送 `vX.Y.Z` tag。
+   - 使用 `releases/vX.Y.Z.md` 创建 GitHub Release。
 
 ## 自动版本校验
 
-`Plugin Version Check` 工作流会在每次 Push / PR 时自动检查版本一致性。
+`Plugin Version Check` 工作流会在 Push / PR 时自动检查:
+
+- 版本元数据一致。
+- README 版本徽章一致。
+- 当前版本有 release note。
+- `CHANGELOG.md` 包含当前版本。
 
-触发文件变更:
+触发文件:
 
 - `.claude-plugin/marketplace.json`
 - `webnovel-writer/.claude-plugin/plugin.json`
 - `webnovel-writer/scripts/sync_plugin_version.py`
+- `webnovel-writer/scripts/validate_release_notes.py`
 - `README.md`
+- `CHANGELOG.md`
+- `releases/**`

+ 55 - 0
releases/README.md

@@ -0,0 +1,55 @@
+# 发布说明维护规则
+
+每次正式发版都必须在这里新增一份 `vX.Y.Z.md`,作为 GitHub Release 正文的唯一来源。
+
+发布说明不是最后一次提交的摘要,而是从上一个正式 tag 到本次发布提交之间的完整用户可感知变化。写之前先运行:
+
+```bash
+git tag --list "v*" --sort=-v:refname
+git log --oneline v上一版本..HEAD
+git diff --stat v上一版本..HEAD
+```
+
+## 写作顺序
+
+1. 先确定发版范围,例如 `v6.1.0..v6.2.0`。
+2. 先写“给作者看的变化”,使用中文网文作者能理解的场景语言。
+3. 再写“是否需要改旧项目”和“已知影响”。
+4. 最后写“给维护者”,记录 CLI、schema、测试、CI、内部结构变化。
+5. 运行 `validate_release_notes.py` 检查格式和范围。
+
+## 固定模板
+
+```md
+# vX.Y.Z - 一句中文用户收益
+
+## 发版范围
+
+本次发布覆盖从 `vA.B.C` 到本发布提交的全部变化。
+
+## 给作者看的变化
+
+- ...
+
+## 是否需要改旧项目
+
+- ...
+
+## 适合谁升级
+
+- ...
+
+## 已知影响
+
+- ...
+
+## 给维护者
+
+- ...
+
+## 验证
+
+- ...
+```
+
+README 只保留一句短摘要;完整变化写在 `CHANGELOG.md` 和本目录的版本文件里。

+ 51 - 0
releases/v6.2.0.md

@@ -0,0 +1,51 @@
+# v6.2.0 - 写章结果更清楚,失败后更好恢复
+
+## 发版范围
+
+本次发布覆盖从 `v6.1.0` 到本发布提交的全部变化,不只是最后一次版本号提交。
+
+## 给作者看的变化
+
+- 命令结束后的结果更好读。初始化、规划、写章和审查会用统一最终报告说明:已完成、部分完成、需要你处理或未完成。
+- 写章失败后更好恢复。重复执行同一条 `/webnovel-write 章号` 时,系统会先检查哪些步骤已经可信完成,尽量从失败位置继续。
+- 过程提示更少打扰。系统默认继续推进,只有创作方向、事实取舍、文件覆盖风险或审查阻断问题需要你裁决时才询问。
+- 写章和审查的中间结果更稳。系统会更严格确认正文、审查结果、故事事实提取、章节提交和故事资料同步是否可信。
+- 最小写章模式更安全。跳过完整审查时,会生成明确的跳过记录,不再伪装成完整审查已通过。
+- 上下文读取更克制。多个 Skill 和 Agent 的提示词变轻,减少无关参考资料占用上下文,长流程更容易保持重点。
+- 项目排查更清楚。不可恢复故障会提示脱敏日志路径,文档也补充了最终报告、断点恢复和运维说明。
+
+## 是否需要改旧项目
+
+不需要。已有书项目可以继续使用,不需要迁移 `.story-system/`、`.webnovel/`、正文、大纲或设定集。
+
+## 适合谁升级
+
+- 经常连续写多章,希望明确知道“这一章到底有没有写完”的作者。
+- 遇到过写章中断、审查阻断、故事资料同步失败后不知道怎么恢复的用户。
+- 使用长篇项目、依赖 Story System 记录事实和伏笔的作者。
+
+## 已知影响
+
+- 这版不会改名现有 `/webnovel-*` 主命令。
+- 这版不会放宽 blocking 审查、章节提交或故事资料同步的校验。
+- 最终报告会隐藏内部 JSON 和长日志;需要排查时再看 `.webnovel/logs/run_last.log`。
+
+## 给维护者
+
+- 新增作者友好报告链路:`author_glossary.py`、`error_catalog.py`、`review_author_view.py`、`user_report.py`。
+- 新增写章恢复与日志能力:`run_ledger.py`、`run_logger.py`。
+- 新增统一 CLI 子命令:`user-report`、`run-ledger`、`run-log`。
+- 收紧 commit artifact、projection writer、write-gate、postcommit 和 review pipeline 的结构化边界。
+- 轻量化 init、plan、write、review、query、learn、dashboard、doctor 等 Skill,以及 context/data/reviewer/deconstruction Agent。
+- 补充 reference loading map、region-read 规则、上下文瘦身审计和 Claude Code 工具基线文档。
+- 增加 prompt integrity、unit tests、behavior eval,覆盖 artifact ownership、minimal write、projection retry、blocking review、断点续跑和日志脱敏。
+- 版本元数据同步到 `6.2.0`。
+
+## 验证
+
+- `python -m pytest webnovel-writer/scripts/data_modules/tests/test_user_report.py webnovel-writer/scripts/data_modules/tests/test_run_ledger.py webnovel-writer/scripts/data_modules/tests/test_run_logger.py webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py -q --no-cov`
+- `python webnovel-writer/scripts/run_behavior_evals.py --format json`
+- `python -m compileall -q webnovel-writer/scripts/data_modules/user_report.py webnovel-writer/scripts/data_modules/run_ledger.py webnovel-writer/scripts/data_modules/run_logger.py webnovel-writer/scripts/data_modules/webnovel.py webnovel-writer/scripts/run_behavior_evals.py`
+- `python -X utf8 webnovel-writer/scripts/sync_plugin_version.py --check --expected-version 6.2.0`
+- `python -X utf8 webnovel-writer/scripts/validate_plugin_package.py`
+- `git diff --check`

+ 18 - 0
webnovel-writer/scripts/sync_plugin_version.py

@@ -16,6 +16,7 @@ VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")
 README_ROW_PATTERN = re.compile(
     r"^\| \*\*v(?P<version>[^\s*]+)(?P<current> \(当前\))?\*\* \| (?P<notes>.*) \|$"
 )
+README_BADGE_PATTERN = re.compile(r"(badge/version-)(?P<version>\d+\.\d+\.\d+)(-brightgreen\.svg)")
 README_HEADERS = {"| 版本 | 说明 |", "| 版本 | 主要变化 |"}
 README_SEPARATORS = {"|------|------|", "|------|----------|"}
 
@@ -77,7 +78,21 @@ def get_readme_current_version(content: str) -> str:
     return str(current_rows[0]["version"])
 
 
+def get_readme_badge_version(content: str) -> str:
+    match = README_BADGE_PATTERN.search(content)
+    if not match:
+        raise ValueError("README.md version badge not found")
+    return str(match.group("version"))
+
+
+def update_readme_badge(content: str, version: str) -> str:
+    if not README_BADGE_PATTERN.search(content):
+        raise ValueError("README.md version badge not found")
+    return README_BADGE_PATTERN.sub(rf"\g<1>{version}\g<3>", content, count=1)
+
+
 def update_readme_release(content: str, version: str, release_notes: str | None) -> str:
+    content = update_readme_badge(content, version)
     lines = content.splitlines()
 
     try:
@@ -146,6 +161,7 @@ def check_versions(expected_version: str | None = None) -> int:
     plugin_version = str(plugin_payload.get("version", ""))
     marketplace_version = str(marketplace_plugin.get("version", ""))
     readme_version = get_readme_current_version(readme_content)
+    readme_badge_version = get_readme_badge_version(readme_content)
 
     mismatches: list[str] = []
     if plugin_version != marketplace_version:
@@ -154,6 +170,8 @@ def check_versions(expected_version: str | None = None) -> int:
         )
     if plugin_version != readme_version:
         mismatches.append(f"plugin.json={plugin_version}, README.md={readme_version}")
+    if plugin_version != readme_badge_version:
+        mismatches.append(f"plugin.json={plugin_version}, README badge={readme_badge_version}")
     if expected_version and plugin_version != expected_version:
         mismatches.append(
             f"expected={expected_version}, current release metadata={plugin_version}"

+ 13 - 0
webnovel-writer/scripts/tests/test_validate_plugin_package.py

@@ -44,6 +44,8 @@ def _write_minimal_package(root: Path, *, plugin_version: str = "1.2.3", marketp
             [
                 "# Test",
                 "",
+                f"[![Version](https://img.shields.io/badge/version-{plugin_version}-brightgreen.svg)](.claude-plugin/marketplace.json)",
+                "",
                 "| 版本 | 说明 |",
                 "|------|------|",
                 f"| **v{plugin_version} (当前)** | test |",
@@ -89,6 +91,17 @@ def test_validate_plugin_package_detects_version_mismatch(tmp_path):
     assert any(item["code"] == "version.marketplace" for item in report["issues"])
 
 
+def test_validate_plugin_package_detects_readme_badge_mismatch(tmp_path):
+    _write_minimal_package(tmp_path)
+    readme = tmp_path / "README.md"
+    readme.write_text(readme.read_text(encoding="utf-8").replace("version-1.2.3", "version-1.2.2"), encoding="utf-8")
+
+    report = validate_package(tmp_path)
+
+    assert report["ok"] is False
+    assert any(item["code"] == "version.readme_badge" for item in report["issues"])
+
+
 def test_validate_plugin_package_detects_missing_skill_frontmatter(tmp_path):
     _write_minimal_package(tmp_path)
     skill = tmp_path / "webnovel-writer" / "skills" / "demo" / "SKILL.md"

+ 98 - 0
webnovel-writer/scripts/tests/test_validate_release_notes.py

@@ -0,0 +1,98 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import json
+import sys
+from pathlib import Path
+
+
+def _ensure_scripts_on_path() -> None:
+    scripts_dir = Path(__file__).resolve().parents[1]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+
+_ensure_scripts_on_path()
+
+from validate_release_notes import validate_release_notes  # noqa: E402
+
+
+def _write_json(path: Path, payload: dict) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def _write_release_files(root: Path, *, version: str = "1.2.3", previous_tag: str = "v1.2.2") -> None:
+    _write_json(
+        root / "webnovel-writer" / ".claude-plugin" / "plugin.json",
+        {"name": "webnovel-writer", "version": version, "description": "desc"},
+    )
+    (root / "CHANGELOG.md").write_text(
+        f"""# 更新日志
+
+## v{version} - 写章结果更清楚
+
+发版范围:`{previous_tag}..v{version}`。
+
+### 给作者看的变化
+
+- 作者写章反馈更清楚。
+""",
+        encoding="utf-8",
+    )
+    release_dir = root / "releases"
+    release_dir.mkdir(parents=True, exist_ok=True)
+    (release_dir / f"v{version}.md").write_text(
+        f"""# v{version} - 写章结果更清楚
+
+## 发版范围
+
+本次发布覆盖从 `{previous_tag}` 到本发布提交的全部变化。
+
+## 给作者看的变化
+
+- 作者写章反馈更清楚。
+
+## 是否需要改旧项目
+
+不需要。
+
+## 给维护者
+
+- 新增校验。
+
+## 验证
+
+- pytest
+""",
+        encoding="utf-8",
+    )
+
+
+def test_validate_release_notes_passes_complete_author_facing_notes(tmp_path):
+    _write_release_files(tmp_path)
+
+    report = validate_release_notes(tmp_path, version="1.2.3", previous_tag="v1.2.2")
+
+    assert report["ok"] is True
+
+
+def test_validate_release_notes_requires_release_file(tmp_path):
+    _write_release_files(tmp_path)
+    (tmp_path / "releases" / "v1.2.3.md").unlink()
+
+    report = validate_release_notes(tmp_path, version="1.2.3", previous_tag="v1.2.2")
+
+    assert report["ok"] is False
+    assert any(item["code"] == "release_note.missing" for item in report["issues"])
+
+
+def test_validate_release_notes_requires_previous_tag_in_release_note(tmp_path):
+    _write_release_files(tmp_path)
+    path = tmp_path / "releases" / "v1.2.3.md"
+    path.write_text(path.read_text(encoding="utf-8").replace("v1.2.2", "上个版本"), encoding="utf-8")
+
+    report = validate_release_notes(tmp_path, version="1.2.3", previous_tag="v1.2.2")
+
+    assert report["ok"] is False
+    assert any(item["code"] == "release_note.range" for item in report["issues"])

+ 10 - 0
webnovel-writer/scripts/validate_plugin_package.py

@@ -152,6 +152,7 @@ def _check_readme_version(root: Path, plugin_version: str, issues: list[dict[str
     try:
         content = readme.read_text(encoding="utf-8")
         readme_version = sync_plugin_version.get_readme_current_version(content)
+        readme_badge_version = sync_plugin_version.get_readme_badge_version(content)
     except Exception as exc:
         issues.append(_issue("version.readme.parse", message=str(exc), path=str(readme), repair="保持 README 版本表格式与 sync_plugin_version.py 一致。"))
         return
@@ -164,6 +165,15 @@ def _check_readme_version(root: Path, plugin_version: str, issues: list[dict[str
                 repair="运行 sync_plugin_version.py --version X.Y.Z --release-notes ...。",
             )
         )
+    if plugin_version and readme_badge_version != plugin_version:
+        issues.append(
+            _issue(
+                "version.readme_badge",
+                message=f"plugin.json={plugin_version}, README badge={readme_badge_version}",
+                path=str(readme),
+                repair="运行 sync_plugin_version.py --version X.Y.Z --release-notes ...。",
+            )
+        )
 
 
 def _check_frontmatter(root: Path, issues: list[dict[str, str]]) -> None:

+ 224 - 0
webnovel-writer/scripts/validate_release_notes.py

@@ -0,0 +1,224 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import subprocess
+from pathlib import Path
+from typing import Any
+
+import sync_plugin_version
+
+
+ROOT = Path(__file__).resolve().parent.parent.parent
+VERSION_RE = sync_plugin_version.VERSION_PATTERN
+REQUIRED_RELEASE_HEADINGS = (
+    "## 发版范围",
+    "## 给作者看的变化",
+    "## 是否需要改旧项目",
+    "## 给维护者",
+    "## 验证",
+)
+AUTHOR_WORDS = ("作者", "写章", "网文", "故事", "正文")
+
+
+def _issue(code: str, *, message: str, path: str = "", repair: str = "") -> dict[str, str]:
+    return {"code": code, "message": message, "path": path, "repair": repair}
+
+
+def _load_text(path: Path) -> tuple[str, str]:
+    try:
+        return path.read_text(encoding="utf-8"), ""
+    except FileNotFoundError:
+        return "", "missing"
+    except OSError as exc:
+        return "", f"read_error:{exc}"
+
+
+def _current_version(root: Path) -> str:
+    payload = sync_plugin_version.load_json(root / "webnovel-writer" / ".claude-plugin" / "plugin.json")
+    return str(payload.get("version") or "")
+
+
+def _parse_version_tag(tag: str) -> tuple[int, int, int] | None:
+    raw = tag[1:] if tag.startswith("v") else tag
+    if not VERSION_RE.fullmatch(raw):
+        return None
+    major, minor, patch = raw.split(".")
+    return int(major), int(minor), int(patch)
+
+
+def _infer_previous_tag(root: Path, version: str) -> str:
+    current = _parse_version_tag(version)
+    if current is None:
+        return ""
+    try:
+        completed = subprocess.run(
+            ["git", "-C", str(root), "tag", "--list", "v*"],
+            check=False,
+            capture_output=True,
+            text=True,
+            encoding="utf-8",
+        )
+    except OSError:
+        return ""
+    if completed.returncode != 0:
+        return ""
+    candidates: list[tuple[tuple[int, int, int], str]] = []
+    for line in completed.stdout.splitlines():
+        tag = line.strip()
+        parsed = _parse_version_tag(tag)
+        if parsed and parsed < current:
+            candidates.append((parsed, tag))
+    if not candidates:
+        return ""
+    return sorted(candidates)[-1][1]
+
+
+def validate_release_notes(
+    root: str | Path | None = None,
+    *,
+    version: str | None = None,
+    previous_tag: str | None = None,
+) -> dict[str, Any]:
+    repo_root = Path(root) if root is not None else ROOT
+    target_version = version or _current_version(repo_root)
+    previous = previous_tag or _infer_previous_tag(repo_root, target_version)
+    issues: list[dict[str, str]] = []
+
+    if not VERSION_RE.fullmatch(target_version):
+        issues.append(_issue("version.invalid", message=f"invalid version: {target_version}", repair="使用 X.Y.Z 版本号。"))
+
+    release_path = repo_root / "releases" / f"v{target_version}.md"
+    release_text, release_error = _load_text(release_path)
+    if release_error:
+        issues.append(
+            _issue(
+                "release_note.missing",
+                message=f"release note {release_path.name} {release_error}",
+                path=str(release_path),
+                repair="新增 releases/vX.Y.Z.md,并覆盖上个 tag 到本次发布的全部变化。",
+            )
+        )
+    else:
+        expected_title = f"# v{target_version} - "
+        if not release_text.startswith(expected_title):
+            issues.append(
+                _issue(
+                    "release_note.title",
+                    message=f"release note title must start with {expected_title!r}",
+                    path=str(release_path),
+                    repair="标题使用 '# vX.Y.Z - 一句中文用户收益'。",
+                )
+            )
+        for heading in REQUIRED_RELEASE_HEADINGS:
+            if heading not in release_text:
+                issues.append(
+                    _issue(
+                        "release_note.heading",
+                        message=f"missing heading: {heading}",
+                        path=str(release_path),
+                        repair="使用 releases/README.md 中的固定模板。",
+                    )
+                )
+        if previous and previous not in release_text:
+            issues.append(
+                _issue(
+                    "release_note.range",
+                    message=f"previous tag {previous} not mentioned",
+                    path=str(release_path),
+                    repair="在“发版范围”中写明从上个正式 tag 到本次发布的范围。",
+                )
+            )
+        if not any(word in release_text for word in AUTHOR_WORDS):
+            issues.append(
+                _issue(
+                    "release_note.audience",
+                    message="release note does not look author-facing",
+                    path=str(release_path),
+                    repair="发布说明顶部必须使用中文网文作者能理解的场景语言。",
+                )
+            )
+
+    changelog_path = repo_root / "CHANGELOG.md"
+    changelog_text, changelog_error = _load_text(changelog_path)
+    if changelog_error:
+        issues.append(
+            _issue(
+                "changelog.missing",
+                message=f"CHANGELOG.md {changelog_error}",
+                path=str(changelog_path),
+                repair="新增 CHANGELOG.md,记录每个正式版本的用户可感知变化。",
+            )
+        )
+    else:
+        if f"## v{target_version}" not in changelog_text:
+            issues.append(
+                _issue(
+                    "changelog.version",
+                    message=f"CHANGELOG.md missing v{target_version}",
+                    path=str(changelog_path),
+                    repair="在 CHANGELOG.md 中新增当前版本小节。",
+                )
+            )
+        if previous and previous not in changelog_text:
+            issues.append(
+                _issue(
+                    "changelog.range",
+                    message=f"CHANGELOG.md does not mention previous tag {previous}",
+                    path=str(changelog_path),
+                    repair="在当前版本小节写明发版范围。",
+                )
+            )
+
+    return {
+        "schema_version": "webnovel-release-notes-validator/v1",
+        "ok": not issues,
+        "root": str(repo_root),
+        "version": target_version,
+        "previous_tag": previous,
+        "release_note": str(release_path),
+        "changelog": str(changelog_path),
+        "issues": issues,
+    }
+
+
+def format_report(report: dict[str, Any], output_format: str = "text") -> str:
+    if output_format == "json":
+        return json.dumps(report, ensure_ascii=False, indent=2)
+    status = "OK" if report.get("ok") else "ERROR"
+    lines = [
+        f"{status} release notes",
+        f"version: {report.get('version')}",
+        f"previous_tag: {report.get('previous_tag') or 'unknown'}",
+    ]
+    for item in report.get("issues") or []:
+        lines.append(f"ERROR {item.get('code')}: {item.get('message')}")
+        if item.get("path"):
+            lines.append(f"  path: {item.get('path')}")
+        if item.get("repair"):
+            lines.append(f"  repair: {item.get('repair')}")
+    return "\n".join(lines)
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Validate author-facing release notes and changelog")
+    parser.add_argument("--root", default="", help="仓库根目录,默认自动推断")
+    parser.add_argument("--version", default="", help="目标版本;默认读取 plugin.json")
+    parser.add_argument("--previous-tag", default="", help="上一个正式 tag;默认从 git tag 推断")
+    parser.add_argument("--format", choices=["text", "json"], default="text")
+    args = parser.parse_args()
+
+    report = validate_release_notes(
+        args.root or None,
+        version=args.version or None,
+        previous_tag=args.previous_tag or None,
+    )
+    print(format_report(report, args.format))
+    return 0 if report.get("ok") else 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())