Răsfoiți Sursa

feat: harden webnovel plugin runtime

lingfengQAQ 3 săptămâni în urmă
părinte
comite
9c4419c480
44 a modificat fișierele cu 7570 adăugiri și 41 ștergeri
  1. 11 3
      README.md
  2. 2 0
      docs/README.md
  3. 1147 0
      docs/architecture/plugin-runtime-hardening-plan-2026-06-04.md
  4. 1388 0
      docs/architecture/plugin-runtime-hardening-spec-2026-06-04.md
  5. 25 1
      docs/guides/commands.md
  6. 50 3
      docs/operations/operations.md
  7. 12 4
      docs/operations/plugin-release.md
  8. 32 1
      webnovel-writer/dashboard/app.py
  9. 130 0
      webnovel-writer/evals/fixtures/behavior/fast.json
  10. 138 0
      webnovel-writer/hooks/guard_runtime_write.py
  11. 39 0
      webnovel-writer/hooks/hooks.json
  12. 66 0
      webnovel-writer/hooks/session_start.py
  13. 297 0
      webnovel-writer/scripts/data_modules/artifact_validator.py
  14. 66 24
      webnovel-writer/scripts/data_modules/chapter_commit_service.py
  15. 576 0
      webnovel-writer/scripts/data_modules/doctor.py
  16. 397 0
      webnovel-writer/scripts/data_modules/project_phase.py
  17. 120 0
      webnovel-writer/scripts/data_modules/project_status.py
  18. 150 0
      webnovel-writer/scripts/data_modules/projection_log.py
  19. 163 0
      webnovel-writer/scripts/data_modules/projections.py
  20. 161 0
      webnovel-writer/scripts/data_modules/tests/test_artifact_validator.py
  21. 16 1
      webnovel-writer/scripts/data_modules/tests/test_dashboard_app.py
  22. 112 0
      webnovel-writer/scripts/data_modules/tests/test_doctor.py
  23. 169 0
      webnovel-writer/scripts/data_modules/tests/test_project_phase.py
  24. 54 0
      webnovel-writer/scripts/data_modules/tests/test_project_status.py
  25. 153 0
      webnovel-writer/scripts/data_modules/tests/test_projection_log.py
  26. 149 0
      webnovel-writer/scripts/data_modules/tests/test_projection_writers.py
  27. 101 0
      webnovel-writer/scripts/data_modules/tests/test_projections_cli.py
  28. 1 1
      webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py
  29. 17 0
      webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py
  30. 201 0
      webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py
  31. 196 0
      webnovel-writer/scripts/data_modules/tests/test_write_gates.py
  32. 8 1
      webnovel-writer/scripts/data_modules/vector_projection_writer.py
  33. 86 1
      webnovel-writer/scripts/data_modules/webnovel.py
  34. 96 0
      webnovel-writer/scripts/data_modules/write_gates/__init__.py
  35. 150 0
      webnovel-writer/scripts/data_modules/write_gates/postcommit.py
  36. 122 0
      webnovel-writer/scripts/data_modules/write_gates/precommit.py
  37. 114 0
      webnovel-writer/scripts/data_modules/write_gates/prewrite.py
  38. 270 0
      webnovel-writer/scripts/run_behavior_evals.py
  39. 95 0
      webnovel-writer/scripts/tests/test_hooks.py
  40. 25 0
      webnovel-writer/scripts/tests/test_run_behavior_evals.py
  41. 100 0
      webnovel-writer/scripts/tests/test_validate_plugin_package.py
  42. 277 0
      webnovel-writer/scripts/validate_plugin_package.py
  43. 70 0
      webnovel-writer/skills/webnovel-doctor/SKILL.md
  44. 18 1
      webnovel-writer/skills/webnovel-write/SKILL.md

+ 11 - 3
README.md

@@ -39,13 +39,14 @@
 | 状态查询 | `/webnovel-query` | 查询角色、伏笔、节奏、实体关系和运行时信息 |
 | 项目学习 | `/webnovel-learn` | 把这本书里好用的写法记下来,存进项目长期记忆 |
 | 可视化面板 | `/webnovel-dashboard` | 只读浏览项目状态、实体图谱、章节内容和追读力数据 |
+| 项目体检 | `/webnovel-doctor` | 阶段感知检查目录、文件、数据库、RAG、依赖和 Dashboard 产物 |
 
 ## 系统长什么样
 
 ```mermaid
 flowchart LR
-    User[作者 / Claude Code] --> Skills[7 个 Skill 命令]
-    Skills --> Agents[Context / Reviewer / Data Agent]
+    User[作者 / Claude Code] --> Skills[8 个 Skill 命令]
+    Skills --> Agents[Context / Reviewer / Data / Deconstruction Agent]
     Agents --> Story[.story-system 合同与提交链]
     Story --> Commit[accepted CHAPTER_COMMIT]
     Commit --> State[.webnovel/state.json]
@@ -61,7 +62,8 @@ v6.0.0 的默认主链叫 **Story System**,几个关键角色:
 - `.story-system/`:唯一的事实源头,动笔前的“合同”和写完后的“提交”都存在这里
 - accepted 的 `CHAPTER_COMMIT`:一章写完,新事实从这里入账
 - `.webnovel/state.json`、`index.db`、`summaries/`、`memory_scratchpad.json`:都是从主链派生出来的只读视图,供查询和展示用
-- `preflight` 和 Dashboard 会把 `story_runtime` 的健康状况直接摆出来,哪里不对一眼就能看到
+- `.webnovel/projection_log.jsonl`:投影执行日志,用来定位 state/index/summary/memory/vector 哪一路没同步
+- `project-status`、`doctor`、`preflight` 和 Dashboard 会把主链与运行状态直接摆出来,哪里不对一眼就能看到
 
 ## 快速开始
 
@@ -185,6 +187,7 @@ Dashboard 是个只读面板,能看项目状态、实体关系图、章节内
 | `/webnovel-query` | `/webnovel-query 萧炎` | 查询角色、伏笔、状态等信息 |
 | `/webnovel-learn` | `/webnovel-learn "这个钩子设计有效"` | 写入项目经验记忆 |
 | `/webnovel-dashboard` | `/webnovel-dashboard` | 启动只读可视化面板 |
+| `/webnovel-doctor` | `/webnovel-doctor --chapter 12` | 只读体检项目文件、DB、RAG 和依赖 |
 
 ### CLI 入口
 
@@ -200,6 +203,10 @@ python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJE
 |--------|------|
 | `where` | 打印当前解析出的书项目根目录 |
 | `preflight` | 校验插件路径、项目根、Story System 健康状态 |
+| `project-status` | 输出机器可读短状态、phase 和下一步 |
+| `doctor` | 阶段感知项目体检,给出影响和修复建议 |
+| `write-gate` | 写前、提交前、提交后三个自然边界校验 |
+| `projections` | 基于已有 commit 补跑或重放投影 |
 | `story-system` | 生成合同种子和 runtime contracts |
 | `chapter-commit` | 提交章节事实并驱动投影 |
 | `story-events` | 查询章节事件或检查事件链健康 |
@@ -249,6 +256,7 @@ npm run dev
 
 ```bash
 python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" preflight
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" doctor --format text
 ```
 
 重点查看:

+ 2 - 0
docs/README.md

@@ -7,6 +7,8 @@
 ### 架构
 
 - [`architecture/overview.md`](./architecture/overview.md):系统架构、Agent 分工、Story System 设计
+- [`architecture/plugin-runtime-hardening-spec-2026-06-04.md`](./architecture/plugin-runtime-hardening-spec-2026-06-04.md):基于优秀 Claude Code 插件调研的运行时可靠性重构 spec
+- [`architecture/plugin-runtime-hardening-plan-2026-06-04.md`](./architecture/plugin-runtime-hardening-plan-2026-06-04.md):运行时可靠性重构实施计划、修改范围与影响分析
 - [`archive/architecture/current-system-diagnosis.md`](./archive/architecture/current-system-diagnosis.md):历史系统状态诊断
 
 ### 使用指南

+ 1147 - 0
docs/architecture/plugin-runtime-hardening-plan-2026-06-04.md

@@ -0,0 +1,1147 @@
+# Plugin Runtime Hardening Implementation Plan
+
+> 日期:2026-06-04
+> 状态:草案 v1
+> 对应 spec:`docs/architecture/plugin-runtime-hardening-spec-2026-06-04.md`
+> 范围:把 spec 拆成可实施、可验收、可回退的工程计划,重点说明修改范围与影响面
+
+---
+
+## 1. 目标
+
+本计划把 `webnovel-writer` 从“主要靠 Skill 文档约束流程”推进到“关键边界由 runtime 可验证”的插件形态。
+
+核心交付:
+
+1. `project_phase` / `project-status`:统一项目阶段推导和短状态摘要,保留现有 `status_reporter.py` 的宏观创作健康报告语义。
+2. `/webnovel-doctor`:阶段感知的项目体检,检查目录、文件、数据库、RAG、Python 依赖、Dashboard 配置,并给修复建议。
+3. `artifact_validator`:统一校验 agent 产物,避免字段漂移和 schema 错误。
+4. `write-gate`:在写前、提交前、提交后三个自然边界做批量校验。
+5. `projection_log`:把 commit 事实和 projection 执行日志拆开。
+6. `projections retry/replay`:投影失败后可补跑。
+7. Skill / Agent 契约补强:按官方 `plugin-dev` 规范收束 frontmatter、description、tools、输出约束。
+8. Behavior evals 与 package validator:验证插件行为和发布物一致性。
+9. 可选轻量 hook:SessionStart 状态提示与 PreToolUse 危险动作兜底提醒 / 阻断。
+
+---
+
+## 2. 实施原则
+
+### 2.1 先观察,后阻断,再迁移
+
+顺序必须是:
+
+1. 先提供只读诊断能力。
+2. 再加入 schema / gate 阻断。
+3. 最后处理 projection log 与 replay。
+
+这样可以减少一次性大改对现有写作流程的冲击。
+
+### 2.2 不破坏现有用户项目
+
+所有新增能力默认兼容旧数据:
+
+- 保留现有 `.story-system/commits/*.commit.json` 结构。
+- 保留 commit 内 `projection_status` 至少一个版本周期。
+- `.webnovel/state.json`、`index.db`、`summaries/`、`memory_scratchpad.json` 继续作为 projection / read-model。
+- Dashboard 先兼容旧字段,再逐步读取新 projection log。
+
+### 2.3 遵循官方 `plugin-dev`
+
+所有插件组件改动必须遵循:
+
+```text
+C:\Users\lcy\.claude\plugins\marketplaces\claude-plugins-official\plugins\plugin-dev
+```
+
+落地要求:
+
+- 插件结构按 `plugin-structure`。
+- Skill 按 `skill-development`,保持 `SKILL.md` 精简,详细规则放 `references/`。
+- Command 按 `command-development`。
+- Agent 按 `agent-development`。
+- Hook 按 `hook-development`,插件级 `hooks/hooks.json` 使用 wrapper 格式。
+- 每轮插件组件改动后按 `plugin-validator` 思路校验 manifest、skills、agents、hooks、README、LICENSE、路径可移植性。
+
+### 2.4 每阶段独立可回退
+
+每阶段应尽量做到:
+
+- 新增文件多于修改旧文件。
+- 旧入口可继续工作。
+- 新 CLI 子命令失败不影响旧命令。
+- 可通过删除新增入口或关闭 hook 回退。
+
+### 2.5 新入口统一 UTF-8
+
+所有新增 CLI / hook / 子进程入口必须兼容 Windows 中文路径:
+
+- CLI 入口调用 `enable_windows_utf8_stdio()` 或等价逻辑。
+- 文件读写显式 `encoding="utf-8"`。
+- hook / 子进程使用 `python -X utf8` 或设置 `PYTHONUTF8=1`。
+- 不依赖系统默认编码。
+
+---
+
+## 3. 总体依赖顺序
+
+```text
+Phase 0 基线审计
+  ↓
+Phase 1 project_phase + project-status + doctor
+  ↓
+Phase 2 artifact_validator
+  ↓
+Phase 3 write-gate
+  ↓
+Phase 4 projection_log
+  ↓
+Phase 5 projection retry/replay
+  ↓
+Phase 6 skill / agent 契约补强
+  ↓
+Phase 7 behavior evals
+  ↓
+Phase 8 package validator
+  ↓
+Phase 9 hooks
+```
+
+说明:
+
+- `project_phase` / `project-status` / `doctor` 可以先做,因为它们只读、风险最低,并且后续 gates 和 hooks 都依赖统一 phase。
+- `artifact_validator` 应早于 `write-gate`,否则 gate 会重复写 schema 判断。
+- `projection_log` 应早于 retry/replay,否则失败记录不稳定。
+- hooks 放后面,因为它们会改变 Claude Code 会话体验。
+
+---
+
+## 4. Phase 0:基线审计与测试冻结
+
+### 4.1 目标
+
+在动代码前确认当前功能基线,避免重构时不知道哪里被破坏。
+
+### 4.2 修改范围
+
+优先不改 runtime 代码,只新增或更新文档 / 测试清单:
+
+- `docs/architecture/plugin-runtime-hardening-plan-2026-06-04.md`
+- 可选更新 `docs/README.md`
+
+### 4.3 工作项
+
+1. 记录当前 CLI 命令表。
+2. 记录现有 Skills、Agents、Dashboard API。
+3. 跑一组最小测试:
+   - `test_webnovel_unified_cli.py`
+   - `test_story_runtime_health.py`
+   - `test_chapter_commit_service.py`
+   - `test_event_projection_router.py`
+   - `test_rag_adapter.py`
+   - `test_dashboard_app.py`
+4. 确认当前 repo 是否已有未提交改动,避免误覆盖用户修改。
+
+### 4.4 影响
+
+无用户可见行为变化。
+
+### 4.5 验收
+
+- 记录基线测试结果。
+- 明确当前失败项是否为既有问题。
+
+---
+
+## 5. Phase 1:`project_phase` / `project-status` / `webnovel-doctor`
+
+### 5.1 目标
+
+新增统一 phase resolver、短状态入口和只读项目体检入口,回答:
+
+- 当前项目处于什么阶段。
+- 这个阶段应该有哪些文件。
+- 目录、JSON、SQLite、RAG、Python 依赖、Dashboard 配置是否完整。
+- 缺失或异常时如何修复。
+
+当前代码已有两个相关入口,必须先明确关系:
+
+- `webnovel.py preflight`:已有快速环境检查,保留并复用。
+- `webnovel.py status`:已转发到 `scripts/status_reporter.py`,语义是宏观创作健康报告,保留不占用。
+
+### 5.2 修改范围
+
+新增:
+
+- `webnovel-writer/scripts/data_modules/project_phase.py`
+- `webnovel-writer/scripts/data_modules/project_status.py`
+- `webnovel-writer/scripts/data_modules/doctor.py`
+- `webnovel-writer/scripts/data_modules/tests/test_project_phase.py`
+- `webnovel-writer/scripts/data_modules/tests/test_project_status.py`
+- `webnovel-writer/skills/webnovel-doctor/SKILL.md`
+- `webnovel-writer/scripts/data_modules/tests/test_doctor.py`
+
+修改:
+
+- `webnovel-writer/scripts/data_modules/webnovel.py`
+- `docs/guides/commands.md`
+- `docs/README.md`
+
+可复用:
+
+- `webnovel.py` 中现有 `_build_preflight_report()`
+- `story_runtime_health.py`
+- `story_runtime_sources.py`
+- `config.py`
+- Dashboard 里的 `_inspect_vector_db()` / `_build_env_status()` 思路
+
+### 5.3 具体工作
+
+1. 实现共享 `project_phase.py`:
+   - 单一 phase 词表。
+   - 不写状态文件。
+   - doctor / project-status / write-gate 共用。
+2. 实现 `project-status`:
+   - `webnovel.py project-status --format json|summary`
+   - 保留 `webnovel.py status` 现有转发,不改 `status_reporter.py` 语义。
+   - 输出 latest accepted chapter、target chapter、phase、warnings、next action。
+3. 实现 `doctor` 数据模型:
+   - `DoctorReport`
+   - `DoctorCheck`
+   - `RepairSuggestion`
+   - `ExpectedFiles`
+4. 由 `project_phase.py` 实现 phase 推导:
+   - `no_project`
+   - `unknown`
+   - `init_scaffolded`
+   - `init_ready`
+   - `plan_in_progress`
+   - `chapter_contract_ready`
+   - `draft_in_progress`
+   - `ready_to_commit`
+   - `chapter_committed`
+   - `projection_failed`
+5. 实现阶段感知 expected files:
+   - init 后只要求骨架、`state.json`、设定集、总纲、`.env.example`。
+   - init 阶段不要求 commit、summary、memory、vectors。
+   - plan / write / commit 阶段再逐步提高要求。
+6. 实现文件检查:
+   - 目录存在性。
+   - JSON 可读性。
+   - 关键字段检查。
+7. 实现 SQLite 检查:
+   - `index.db` 是否可打开。
+   - 关键表是否存在。
+   - 行数统计。
+   - `vectors.db` 是否可打开、是否有 `vectors` 表。
+8. 实现系统配置检查:
+   - Python 版本。
+   - 核心包 import。
+   - RAG env / `.env` 配置。
+   - Dashboard dist / requirements / package.json。
+9. 在统一 CLI 注册:
+   - `webnovel.py project-status --format json|summary`
+   - `webnovel.py doctor --format json|text`
+   - `webnovel.py doctor --chapter N --format json|text`
+   - `webnovel.py doctor --deep --format json|text`
+10. 新增 `/webnovel-doctor` Skill:
+   - 只读。
+   - 不修复。
+   - 输出结论、影响、建议命令。
+
+### 5.4 影响
+
+用户影响:
+
+- 新增一个体检命令,不改变旧流程。
+- 新增 `project-status` 短状态命令,不改变既有 `status` 健康报告。
+- 出问题时用户能看到缺什么、影响什么、怎么修。
+
+代码影响:
+
+- `webnovel.py` 增加 `project-status` 和 `doctor` 子命令。
+- 新增 `doctor.py` 只读模块。
+- 新增共享 phase resolver。
+- 不修改 commit、state、index、summary、memory。
+
+风险:
+
+- phase 推导不准会导致误报。
+- 数据库表清单如果过严,会把旧项目误判为坏。
+- `project-status` 与现有 `status_reporter.py` 混淆。
+
+控制:
+
+- phase 不确定时只做低风险检查。
+- init 阶段缺后续产物只返回 `skip/info`。
+- 数据库检查分 `required` 和 `observed`,避免旧表缺失直接阻断。
+- 命令名使用 `project-status`,保留 `status` 原语义。
+- doctor 快检部分复用 `_build_preflight_report()`,避免 preflight / doctor 两套环境检查漂移。
+
+### 5.5 验收
+
+- 空目录返回 `no_project`,无 traceback。
+- `webnovel.py status` 仍运行现有 `status_reporter.py`。
+- `webnovel.py project-status --format json` 返回统一 phase。
+- `preflight` 仍可运行,并与 doctor 快检结果不冲突。
+- init 刚结束返回 `init_scaffolded` 或 `init_ready`。
+- init 刚结束缺 commit / summary / vectors 不报 blocker。
+- `index.db` 缺关键表时能显示表名、影响、修复建议。
+- 缺 RAG key 返回 warning,并说明降级 BM25。
+- 默认模式不联网、不写文件、不安装依赖、不启动服务。
+- Windows 中文路径下不因默认编码失败。
+
+### 5.6 回退
+
+- 移除 CLI `doctor` / `project-status` 注册。
+- 保留 `doctor.py` 不被调用也不影响现有流程。
+- 保留 `project_phase.py` 不被调用也不影响现有流程。
+- 删除 `/webnovel-doctor` Skill 后插件仍可按原方式运行。
+
+---
+
+## 6. Phase 2:Artifact Validator
+
+### 6.1 目标
+
+统一校验 agent 产物,避免 `review_result`、`fulfillment_result`、`disambiguation_result`、`extraction_result` 字段漂移。
+
+### 6.2 修改范围
+
+新增:
+
+- `webnovel-writer/scripts/data_modules/artifact_validator.py`
+- `webnovel-writer/scripts/data_modules/tests/test_artifact_validator.py`
+
+可能修改:
+
+- `chapter_commit_service.py`
+- `chapter_commit.py`
+- `chapter_commit_schema.py`
+
+### 6.3 具体工作
+
+1. 定义统一错误类型:
+   - `schema_error`
+   - `missing_artifact`
+   - `blocking_review`
+   - `missed_outline_node`
+   - `pending_disambiguation`
+   - `projection_failure`
+2. 包装现有 Pydantic schema。权威源统一为 `chapter_commit_schema.py` 中 commit 所需模型:
+   - `ReviewResult`
+   - `FulfillmentResult`
+   - `DisambiguationResult`
+   - `ExtractionResult`
+3. 明确同名 / 近名模型边界:
+   - `review_schema.py` 是 reviewer / review pipeline 局部模型。
+   - `entity_linker.py` 中的消歧模型是实体链接局部模型。
+   - artifact_validator 只以 commit artifact schema 作为最终提交权威。
+4. 提供统一入口:
+   - `validate_review_result(path)`
+   - `validate_fulfillment_result(path)`
+   - `validate_disambiguation_result(path)`
+   - `validate_extraction_result(path)`
+   - `validate_chapter_commit(path)`
+5. 允许兼容已知旧字段,无法兼容时给明确诊断。
+
+### 6.4 影响
+
+用户影响:
+
+- 提交前更早发现 agent 输出错误。
+- 报错从 Python traceback 变成结构化说明。
+
+代码影响:
+
+- `chapter_commit_service` 可以逐步改为依赖 validator。
+- 后续 `write-gate` 复用 validator,减少重复校验。
+
+风险:
+
+- 过严 schema 可能阻断旧产物。
+- 同名模型选错会制造新的 schema 漂移。
+
+控制:
+
+- 首版对旧字段做兼容或 warning。
+- 只有明确影响 commit 正确性的错误才 blocker。
+- 在代码注释和测试中固定权威源为 `chapter_commit_schema.py`。
+
+### 6.5 验收
+
+- 缺 artifact 返回 `missing_artifact`。
+- JSON 外层包错返回 `schema_error`。
+- `disambiguation.pending` 非空返回 blocker。
+- reviewer blocking issue 返回 blocker。
+- `ReviewResult` / `DisambiguationResult` 同名模型不混用。
+
+### 6.6 回退
+
+- `chapter_commit_service` 保留旧校验路径。
+- 如果 validator 有误,可先只用于 doctor / gate 报告,不阻断 commit。
+
+---
+
+## 7. Phase 3:Runtime Gates
+
+### 7.1 目标
+
+新增写章关键边界校验:
+
+- `prewrite`
+- `precommit`
+- `postcommit`
+
+### 7.2 修改范围
+
+新增:
+
+- `webnovel-writer/scripts/data_modules/write_gates/__init__.py`
+- `webnovel-writer/scripts/data_modules/write_gates/prewrite.py`
+- `webnovel-writer/scripts/data_modules/write_gates/precommit.py`
+- `webnovel-writer/scripts/data_modules/write_gates/postcommit.py`
+- `webnovel-writer/scripts/data_modules/tests/test_write_gates.py`
+
+修改:
+
+- `webnovel-writer/scripts/data_modules/webnovel.py`
+- `webnovel-writer/skills/webnovel-write/SKILL.md`
+- `docs/guides/commands.md`
+
+复用:
+
+- `webnovel-writer/scripts/data_modules/prewrite_validator.py`
+- `webnovel-writer/scripts/data_modules/tests/test_prewrite_validator.py`
+
+### 7.3 具体工作
+
+1. 注册 CLI:
+   - `webnovel.py write-gate --chapter N --stage prewrite --format json`
+   - `webnovel.py write-gate --chapter N --stage precommit --format json`
+   - `webnovel.py write-gate --chapter N --stage postcommit --format json`
+2. `prewrite` 检查必须包装 `PrewriteValidator`:
+   - project root。
+   - phase 是否允许写。
+   - Story System 合同是否齐。
+   - 占位符 blocker。
+3. `precommit` 检查:
+   - 正文文件。
+   - review / fulfillment / disambiguation / extraction artifacts。
+   - artifact validator。
+   - blocking issue。
+4. `postcommit` 检查:
+   - commit 文件。
+   - `projection_status`。
+   - summary / index / memory / backup 基本存在性。
+5. 更新 `/webnovel-write`:
+   - 用 Claude Code Todo 管过程。
+   - 只在自然边界调用 gate。
+
+### 7.4 影响
+
+用户影响:
+
+- 写章流程增加 2-3 次确定性检查。
+- 提交前错误更清晰。
+
+代码影响:
+
+- `/webnovel-write` 的执行说明会改变。
+- `webnovel.py` 增加子命令。
+- 现有 `PrewriteValidator` 成为 prewrite gate 的底层实现,避免两套逻辑漂移。
+
+风险:
+
+- gate 太严格会打断写作体验。
+- gate 太宽松则无法提升可靠性。
+- 如果重写 prewrite 逻辑,会和现有 `PrewriteValidator` 漂移。
+
+控制:
+
+- 首版只阻断明确不可信状态。
+- warning 不阻断。
+- 所有 gate 输出 repair 建议。
+- prewrite 不重写,先适配现有 validator 输出。
+
+### 7.5 验收
+
+- 缺 review artifact 时 `precommit.ok=false`。
+- blocking review 时 `precommit.ok=false`。
+- disambiguation pending 时 `precommit.ok=false`。
+- projection failed 时 `postcommit.ok=false`。
+- init 阶段调用 `prewrite` 能给出明确下一步建议。
+- 现有 `test_prewrite_validator.py` 继续通过。
+
+### 7.6 回退
+
+- `/webnovel-write` 可临时回到旧流程。
+- CLI 子命令保留但不被 Skill 调用。
+
+---
+
+## 8. Phase 4:Projection Log
+
+### 8.1 目标
+
+将 commit 事实和 projection 执行状态拆开,降低 commit 文件同时承载事实与执行日志的混乱。
+
+开工前必须先确认现状痛点:
+
+- 是否出现过 projection failed 后无法定位 writer。
+- 是否出现过 commit 内 `projection_status` 与实际 read-model 不一致。
+- Dashboard / doctor 是否确实需要跨 writer 的执行历史。
+
+如果没有真实痛点,本阶段可以延后,只保留 doctor 对现有 `projection_status` 的诊断。
+
+### 8.2 修改范围
+
+新增:
+
+- `webnovel-writer/scripts/data_modules/projection_log.py`
+- `webnovel-writer/scripts/data_modules/tests/test_projection_log.py`
+
+修改:
+
+- `chapter_commit_service.py`
+- `event_projection_router.py`
+- `story_runtime_health.py`
+- `doctor.py`
+- `dashboard/app.py`
+
+### 8.3 具体工作
+
+1. 新增 JSONL projection log:
+   - `.webnovel/projection_log.jsonl`
+2. 定义 run schema:
+   - `run_id`
+   - `chapter`
+   - `commit_path`
+   - `commit_hash`
+   - `writer`
+   - `status`
+   - `started_at`
+   - `finished_at`
+   - `error`
+3. `chapter_commit_service.apply_projections()` 每个 writer 写一条 log。
+4. 保留 commit 内 `projection_status` 双写。
+5. doctor 优先读取 projection log,缺失时 fallback 到 commit 内字段。
+6. Dashboard 先兼容读取,不做大改版。
+
+### 8.4 影响
+
+用户影响:
+
+- 投影失败时能看到具体哪个 writer 失败。
+- 不改变章节提交命令。
+
+数据影响:
+
+- 新增 `.webnovel/projection_log.jsonl`。
+- commit 文件结构暂时不删字段。
+
+风险:
+
+- 双写不一致。
+- Dashboard 读取逻辑复杂一点。
+- 新增 projection log 会形成第二份执行状态来源。
+
+控制:
+
+- projection log 写失败不应影响 commit 主流程,但必须 warning。
+- doctor 报告双写不一致。
+- 保留一个明确决策点:确认收益大于双写复杂度后再施工。
+
+### 8.5 验收
+
+- 每个 writer 有 projection log。
+- 单 writer failed 不影响其他 writer 记录。
+- doctor 能指出 failed writer。
+- commit 内 `projection_status` 仍存在。
+
+### 8.6 回退
+
+- Dashboard 和 doctor fallback 到 commit 内 `projection_status`。
+- 删除 projection log 不影响 commit 读取。
+
+---
+
+## 9. Phase 5:Projection Retry / Replay
+
+### 9.1 目标
+
+投影失败后能按 writer 补跑,尤其是 vector / summary / memory。
+
+本阶段风险最高,必须先完成 writer 幂等性审计和测试。
+
+### 9.2 修改范围
+
+新增:
+
+- `webnovel-writer/scripts/data_modules/projection_runner.py`
+- `webnovel-writer/scripts/data_modules/tests/test_projection_runner.py`
+
+修改:
+
+- `event_projection_router.py`
+- `webnovel.py`
+- projection writer 测试
+
+### 9.3 具体工作
+
+1. 新增 CLI:
+   - `webnovel.py projections status --chapter N`
+   - `webnovel.py projections retry --chapter N --writer vector`
+   - `webnovel.py projections retry-failed --chapter N`
+   - `webnovel.py projections replay --from 1 --to 20 --writers state,index,summary`
+2. 先审计 5 个 writer 幂等性:
+   - `state`
+   - `index`
+   - `summary`
+   - `memory`
+   - `vector`
+3. 补齐 writer 幂等测试,尤其关注:
+   - 字数重复累计。
+   - 关系 / 事件重复插入。
+   - memory 重复沉淀。
+   - vector chunk 重复。
+4. runner 只读取 accepted commit。
+5. retry 不修改 commit 事实内容。
+6. retry 结果写 projection log,并兼容更新旧 `projection_status`。
+
+### 9.4 影响
+
+用户影响:
+
+- 外部依赖失败后不用重写整章。
+- 可单独补 vector / summary。
+
+数据影响:
+
+- projection read-model 可能被重建。
+- 需要保证幂等,避免重复累计字数或重复关系。
+
+风险:
+
+- 幂等不足导致重复数据。
+- replay 命令一旦写错,影响范围比单章 commit 大。
+
+控制:
+
+- 先对 state/index/summary/memory/vector writer 分别补幂等测试。
+- replay 默认要求明确 chapter 范围。
+- 默认不提供全书无边界 replay。
+
+### 9.5 验收
+
+- 删除 summary 后 retry summary 可恢复。
+- 重复 replay 同一章节不会重复累计 state / index / memory / vector 数据。
+- vector key 缺失时 vector failed,其余 writer done。
+- 配置 key 后 retry vector 只补 vector。
+- replay 后 state/index/summary 与 commit 链一致。
+
+### 9.6 回退
+
+- 隐藏或下线 projections CLI。
+- 继续使用旧 chapter commit 流程。
+
+---
+
+## 10. Phase 6:Skill / Agent 契约补强
+
+### 10.1 目标
+
+按官方 `plugin-dev` 规范增强 Skill / Agent 的触发、工具范围、输出契约。
+
+### 10.2 修改范围
+
+修改:
+
+- `webnovel-writer/skills/*/SKILL.md`
+- `webnovel-writer/agents/*.md`
+- `webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py`
+
+可选新增:
+
+- `webnovel-writer/agents/continuity-reviewer.md`
+- `webnovel-writer/agents/style-reviewer.md`
+- `webnovel-writer/agents/reader-pull-reviewer.md`
+
+### 10.3 具体工作
+
+1. Skill frontmatter:
+   - 补强 `description`,写清触发场景和不适用场景。
+   - 保持 `SKILL.md` 精简。
+   - 大段规则移动到 `references/`。
+2. Agent frontmatter:
+   - 补 `name`。
+   - 补具体 `description`。
+   - 明确 `tools`。
+   - 需要时补 `model`。
+3. Agent 输出契约:
+   - reviewer 输出维度固定。
+   - data-agent 明确只产出 artifacts,不写 projection。
+   - context-agent 明确上下文优先级。
+4. 用 plugin-dev 的 validate-agent 规则做人工或脚本校验。
+
+### 10.4 影响
+
+用户影响:
+
+- Claude Code 触发技能和代理更稳定。
+- 误触发和漏触发减少。
+
+代码影响:
+
+- 主要是 prompt / markdown 文件。
+- 可能影响 Claude Code 的选择行为。
+
+风险:
+
+- description 改动导致触发习惯变化。
+
+控制:
+
+- 改一组测一组。
+- 增加 prompt integrity 测试。
+- 保留命令名称不变。
+
+### 10.5 验收
+
+- 7 个 Skill 都有明确触发型 description。
+- 4 个现有 Agent frontmatter 符合 plugin-dev 要求。
+- prompt integrity 测试通过。
+- data-agent 文档仍明确不写 state/index/summary/memory。
+
+### 10.6 回退
+
+- 单个 Skill / Agent 可独立回滚 frontmatter。
+- 不影响 Python runtime。
+
+---
+
+## 11. Phase 7:Behavior Evals
+
+### 11.1 目标
+
+验证插件在真实行为层面是否按协议执行,而不是只验证 Python 函数。
+
+### 11.2 修改范围
+
+新增:
+
+- `webnovel-writer/evals/`
+- `webnovel-writer/scripts/run_behavior_evals.py`
+- `webnovel-writer/evals/fixtures/`
+
+修改:
+
+- CI / 本地测试文档。
+- `docs/operations/operations.md`
+
+### 11.3 具体工作
+
+1. 建立 eval 分类:
+   - skill triggering
+   - workflow behavior
+   - agent output schema
+   - continuity conflict
+   - memory commit
+2. 首批用例:
+   - init 不污染插件目录。
+   - write 遇 blocking issue 不进入 commit。
+   - data-agent 不写 projection。
+   - commit 驱动 projection。
+   - dashboard 只读。
+3. Runner 输出 JSON 报告。
+4. 区分 fast fixture eval 和 slow transcript eval。
+
+### 11.4 影响
+
+用户影响:
+
+- 无直接使用变化。
+- 发布前可靠性更高。
+
+代码影响:
+
+- 增加测试资产。
+- CI 时间可能增加。
+
+风险:
+
+- transcript eval 成本高、慢。
+
+控制:
+
+- 默认只跑 fast。
+- slow 只在发布前或手动运行。
+
+### 11.5 验收
+
+- 每个 Skill 至少一个 eval。
+- `/webnovel-write` 覆盖成功链路和 blocking 链路。
+- eval report 有 pass/fail/reason/artifacts。
+
+### 11.6 回退
+
+- eval 不参与默认流程时可暂时跳过。
+- 不影响 runtime。
+
+---
+
+## 12. Phase 8:Package Validator
+
+### 12.1 目标
+
+防止 manifest、marketplace、README、version、frontmatter 漂移。
+
+### 12.2 修改范围
+
+新增:
+
+- `webnovel-writer/scripts/validate_plugin_package.py`
+- `webnovel-writer/scripts/tests/test_validate_plugin_package.py`
+
+修改:
+
+- `docs/operations/plugin-release.md`
+- `docs/guides/commands.md`
+
+### 12.3 具体工作
+
+1. 检查 `.claude-plugin/plugin.json`:
+   - name kebab-case。
+   - version semver。
+   - description 非空。
+2. 检查 marketplace 文件。
+3. 复用或对齐现有 Plugin Version Check:
+   - marketplace version。
+   - plugin.json version。
+   - README 中既有版本位置 / 版本表。
+   - 不新增一套与现有 CI 冲突的 README badge 规则。
+4. 检查 skills frontmatter。
+5. 检查 agents frontmatter。
+6. 检查 hooks schema。
+7. 检查 LICENSE。
+8. 检查 Dashboard dist。
+9. 检查无硬编码本机绝对路径。
+
+### 12.4 影响
+
+用户影响:
+
+- 发布包更稳定。
+
+代码影响:
+
+- 新增发布前校验脚本。
+
+风险:
+
+- 校验规则过严影响开发阶段。
+- 版本校验与现有 CI 不一致,导致本地通过但 CI 失败,或相反。
+
+控制:
+
+- 区分 `--strict` 和默认模式。
+- 默认只阻断明显错误。
+- 先读现有 CI / release 文档,再实现版本检查。
+
+### 12.5 验收
+
+- clean clone 校验通过。
+- version 任一处漂移时失败。
+- 版本漂移规则与现有 CI 一致。
+- 删除 Skill frontmatter 时失败。
+- hooks 路径不用 `${CLAUDE_PLUGIN_ROOT}` 时 warning 或失败。
+
+### 12.6 回退
+
+- release 流程暂不调用 validator。
+- 不影响插件运行。
+
+---
+
+## 13. Phase 9:Hooks
+
+### 13.1 目标
+
+基于 Phase 1 的 `project-status` 提供轻量状态摘要,并对最危险的绕过 runtime 写入做兜底提醒 / 阻断。
+
+### 13.2 修改范围
+
+新增:
+
+- `webnovel-writer/hooks/hooks.json`
+- `webnovel-writer/hooks/session_start.py`
+- `webnovel-writer/hooks/scripts/guard-runtime-write.py`
+
+修改:
+
+- `webnovel-writer/scripts/data_modules/webnovel.py`
+- `webnovel-writer/.claude-plugin/plugin.json`,仅在需要显式声明 hook 路径时修改。
+- `docs/operations/operations.md`
+
+### 13.3 具体工作
+
+1. `SessionStart` hook:
+   - 只输出短摘要。
+   - 不写文件。
+   - 不运行完整 doctor。
+   - 调用 `webnovel.py project-status --format summary`。
+   - 可通过 env 关闭。
+2. `PreToolUse` hook:
+   - 阻断直接写 `.story-system/commits`。
+   - 阻断直接写 `.webnovel/state.json`、`index.db`、`memory_scratchpad.json`。
+   - 对 Bash 中绕过 gate 的危险 commit / projection 命令做 best-effort 检测。
+   - 不把 Bash 字符串解析当作唯一硬保证。
+3. 按 plugin-dev hook-development 校验:
+   - `hooks/hooks.json` 使用 wrapper 格式。
+   - hook 命令使用 `${CLAUDE_PLUGIN_ROOT}`。
+   - hook 脚本校验 stdin JSON。
+
+### 13.4 影响
+
+用户影响:
+
+- 新对话能看到短状态。
+- 直接改主链 / projection 文件会被阻断或要求显式走 runtime。
+
+代码影响:
+
+- 新增 hooks 目录。
+- Claude Code 会话启动多一次轻量命令。
+
+风险:
+
+- hook 输出太长影响上下文。
+- hook 误阻断开发者调试。
+- Bash 命令变体太多,hook 无法可靠识别全部绕过方式。
+
+控制:
+
+- 输出限制 8 行 / 1000 字符。
+- 提供关闭 env。
+- 先只阻断最危险路径。
+- 真正可靠性仍由 runtime gate 和 commit 入口保证。
+
+### 13.5 验收
+
+- 无项目根时不报错。
+- 有项目根时输出 latest chapter / phase / next action。
+- 设置 disable env 后无输出。
+- 直接写 commit 文件被阻断。
+- 合法 runtime 命令不被阻断。
+- `webnovel.py status` 仍保留宏观创作健康报告语义。
+
+### 13.6 回退
+
+- 删除或禁用 `hooks/hooks.json`。
+- 保留 `project-status` CLI 不影响旧流程。
+
+---
+
+## 14. 横向影响分析
+
+### 14.1 项目健康入口归属
+
+| 入口 | 当前/目标职责 | 输出 | 是否深检 | 是否写文件 |
+|---|---|---|---|---|
+| `preflight` | 快速环境检查,保留兼容 | text/json | 否 | 否 |
+| `project-status` | 机器可读短状态、phase、下一步 | summary/json | 否 | 否 |
+| `doctor` | 文件/数据库/配置体检和修复建议 | text/json | 默认否,`--deep` 可选 | 否 |
+| `status` / `status_reporter.py` | 宏观创作健康报告,如角色、伏笔、爽点、关系图谱 | markdown/text | 是,偏创作分析 | 现状可能输出报告文件,语义保持 |
+| `build_story_runtime_health()` | 内部主链就绪度 helper | dict | 否 | 否 |
+
+原则:
+
+- 不把所有问题塞进一个命令。
+- 不改变现有 `status` 语义。
+- doctor 复用 preflight 和 story runtime health,不复制逻辑。
+
+### 14.2 对用户命令的影响
+
+新增命令:
+
+- `/webnovel-doctor`
+- `webnovel.py doctor`
+- `webnovel.py write-gate`
+- `webnovel.py projections`
+- `webnovel.py project-status`
+
+现有命令保持:
+
+- `/webnovel-init`
+- `/webnovel-plan`
+- `/webnovel-write`
+- `/webnovel-review`
+- `/webnovel-query`
+- `/webnovel-dashboard`
+- `/webnovel-learn`
+
+现有 `webnovel.py status` 保持转发到 `status_reporter.py`。
+
+### 14.3 对项目数据的影响
+
+新增文件:
+
+- `.webnovel/projection_log.jsonl`
+- 可能新增 `.webnovel/tmp/*` 的校验约定。
+
+不改变:
+
+- `.story-system/` 主链真源地位。
+- accepted commit 是写后事实入口。
+- `.webnovel/*` 是 projection / read-model。
+
+### 14.4 对 Dashboard 的影响
+
+短期:
+
+- Dashboard 保持只读。
+- 继续兼容 commit 内 `projection_status`。
+
+中期:
+
+- Dashboard 可展示 projection log。
+- System 页可展示 doctor / project-status 摘要。
+
+风险:
+
+- Dashboard 前端 bundle 可能需要 rebuild。
+
+### 14.5 对 RAG 的影响
+
+默认:
+
+- 缺 key 降级 BM25。
+- `vectors.db` 缺失只 warning。
+
+深度检查:
+
+- 才测试 API 连通。
+
+### 14.6 对测试的影响
+
+新增测试量较大,需要分层:
+
+- 单元测试:doctor / validator / gates / projection log。
+- 集成测试:chapter commit + projection。
+- 行为测试:skill / agent 协议。
+- 发布测试:package validator。
+
+---
+
+## 15. 建议 PR 切分
+
+### PR 1:Phase Resolver + Project Status + Doctor
+
+包含:
+
+- shared `project_phase`。
+- `project-status` CLI。
+- doctor runtime。
+- doctor CLI。
+- `/webnovel-doctor` Skill。
+- doctor tests。
+- preflight 快检复用关系。
+
+不包含:
+
+- write-gate。
+- projection log。
+- hooks。
+
+### PR 2:Validator + Gates
+
+包含:
+
+- artifact validator。
+- write-gates。
+- prewrite gate 包装现有 `PrewriteValidator`。
+- `/webnovel-write` 更新。
+- gate tests。
+
+### PR 3:Projection Writer 幂等审计
+
+包含:
+
+- state / index / summary / memory / vector writer 幂等测试。
+- replay 风险评估。
+
+### PR 4:Projection Log
+
+包含:
+
+- projection log。
+- chapter commit 双写。
+- doctor / dashboard 兼容读取。
+
+### PR 5:Projection Retry / Replay
+
+包含:
+
+- projection runner。
+- projections CLI。
+- writer 幂等测试。
+
+### PR 6:Skill / Agent 契约
+
+包含:
+
+- skill frontmatter。
+- agent frontmatter。
+- prompt integrity tests。
+
+### PR 7:Evals + Package Validator
+
+包含:
+
+- behavior eval runner。
+- validate plugin package。
+- release docs。
+
+### PR 8:Hooks
+
+包含:
+
+- SessionStart hook。
+- PreToolUse guard。
+- plugin-dev hook validation。
+
+---
+
+## 16. 总体验收
+
+完成后应满足:
+
+1. 用户能运行 `/webnovel-doctor` 看懂项目文件、数据库、配置是否正常。
+2. `project-status` 能给短状态,且不占用现有 `status_reporter.py`。
+3. init 刚结束不会因为缺 commit / summary / vectors 被误报。
+4. 写章只在三个自然边界增加 gate 检查。
+5. agent 产物 schema 错误能被统一报告。
+6. projection 失败能定位 writer,并能补跑。
+7. commit 事实和 projection 执行日志可区分。
+8. Skill / Agent / Hook 结构符合官方 `plugin-dev` 规范。
+9. 发布前能校验插件包一致性。
+
+---
+
+## 17. 最小先行版本
+
+如果要尽快落地一版高收益版本,建议只做前三项:
+
+1. `doctor`
+2. `project-status` / `project_phase`
+3. `artifact_validator`
+4. `write-gate`
+
+这三项能先解决最核心的问题:
+
+- 用户知道项目哪里坏。
+- runtime 和短状态共用同一套 phase。
+- runtime 知道 agent 产物是否可信。
+- 写章关键边界不再只靠文档约束。
+
+Projection log、retry/replay、hooks 和 evals 可以在基础稳定后继续推进。

+ 1388 - 0
docs/architecture/plugin-runtime-hardening-spec-2026-06-04.md

@@ -0,0 +1,1388 @@
+# Plugin Runtime Hardening Spec
+
+> 日期:2026-06-04
+> 状态:草案 v1
+> 范围:基于优秀 Claude Code 插件调研,对 `webnovel-writer` 的插件形态、运行时可靠性、workflow 编排、doctor 自检、hook 状态感知、eval 与发布治理做系统收束
+> 调研样本:`anthropics/claude-plugins-official`、`anthropics/skills`、`obra/superpowers`、`SonarSource/sonarqube-agent-plugins`、`appwrite/claude-plugin`、`aws-samples/sample-claude-code-plugins-for-startups`、社区多插件 marketplace
+
+---
+
+## 1. 背景
+
+`webnovel-writer` 当前已经不是普通单一 Skill,而是一个完整的长篇写作运行时插件:
+
+- 7 个 Skill 命令负责 init / plan / write / review / query / learn / dashboard。
+- 4 个 Agent 负责写前上下文、审查、事实提取、参考拆解。
+- Python CLI 与 `data_modules` 承担 Story System、commit、projection、RAG、memory、Dashboard 数据层。
+- `.story-system/` 是合同与提交主链,`.webnovel/*` 是 projection / read-model。
+
+优秀 Claude Code 插件的共同经验是:
+
+1. `SKILL.md` 做路由和流程,不承载全部知识。
+2. 确定性动作下沉到脚本 / runtime / MCP,而不是靠 prompt 约束。
+3. `commands / skills / agents / hooks / MCP` 边界清楚。
+4. hooks 只做轻量状态提示、自检或接线,不做重业务。
+5. 复杂 workflow 有可验证的输入、输出、停止条件和验收标准。
+6. 有 `doctor / integrate / setup` 类环境自检入口。
+7. 有真实行为 eval,证明 agent 会按协议执行。
+8. manifest、marketplace、README、版本、LICENSE 有校验,避免漂移。
+
+本 spec 的目标是把这些经验转化为 `webnovel-writer` 的下一阶段架构改造路线。
+
+---
+
+## 2. 一句话目标
+
+把 `webnovel-writer` 从“强 Skill 包 + Python 工具链”升级为:
+
+> 可自检、可验证、可恢复、可重放、可发版治理的长篇写作运行时插件。
+
+---
+
+## 3. 设计原则
+
+### 3.1 Runtime First
+
+写章、提交、投影、校验等关键链路必须由 runtime 保证,不再主要依赖 Skill 文档中的自然语言步骤。
+
+### 3.2 Skill as Router
+
+`SKILL.md` 保留:
+
+- 何时触发
+- 决策树
+- 高层流程
+- 必读/按需引用路由
+- 失败处理边界
+
+`SKILL.md` 不应承担:
+
+- 长命令拼接细节
+- schema 校验逻辑
+- projection 修复逻辑
+- 大段题材知识
+- 可程序化验证规则
+
+### 3.3 Commit Is Fact
+
+`CHAPTER_COMMIT` 是写后事实,不应和 projection 执行日志混在一起。事实记录与投影执行状态要逐步解耦。
+
+### 3.4 Hooks Are Advisory Guards
+
+hooks 可以承担“自动触发的轻量守卫”,但不能成为隐藏业务流程。
+
+允许:
+
+- SessionStart 项目状态摘要
+- 依赖 / 配置提醒
+- doctor 入口提示
+- dashboard / RAG / Story System 健康提示
+- skill-scoped 固定预检,且通过时静默
+- PreToolUse 对危险写入 / commit 命令做硬阻断
+
+禁止:
+
+- 自动写 state / commit / memory
+- 自动安装外部依赖
+- 自动修改正文或设定
+- 注入大段创作方法论
+- 作为章节主状态机写入 step state
+- 每个步骤都用 hook 自动打点
+- 在用户不可见的情况下推进写作流程
+
+### 3.5 Behavior Must Be Tested
+
+现有 Python 单元测试继续保留,但不足以证明插件行为。必须增加 skill / agent 工作流级 eval。
+
+### 3.6 UTF-8 First
+
+本项目大量读取中文路径和中文文件名,新入口必须显式 UTF-8:
+
+- Python CLI 入口调用 `enable_windows_utf8_stdio()` 或等价逻辑。
+- 所有文本读取 / 写入显式 `encoding="utf-8"`。
+- hook / 子进程命令优先使用 `python -X utf8`,或显式设置 `PYTHONUTF8=1`。
+- doctor / project-status / write-gate / hook 脚本不得依赖系统默认编码。
+
+### 3.7 Follow Official `plugin-dev`
+
+后续对本插件的任何新增或修改,必须先遵循官方 `plugin-dev` 插件的指导:
+
+```text
+C:\Users\lcy\.claude\plugins\marketplaces\claude-plugins-official\plugins\plugin-dev
+```
+
+落地约束:
+
+- 插件结构遵循 `plugin-structure`:`.claude-plugin/plugin.json` 必须在插件根的 `.claude-plugin/` 下;`commands/`、`agents/`、`skills/`、`hooks/` 位于插件根层级。
+- 所有插件内路径使用 `${CLAUDE_PLUGIN_ROOT}`,不在 manifest / hook / command 中写死本机绝对路径。
+- 新增 Skill 遵循 `skill-development`:`SKILL.md` 必须有 `name`、具体触发型 `description`、可选 `version`;正文保持精简,详细规则放入 `references/`,确定性脚本放入 `scripts/`。
+- 新增 Command 遵循 `command-development`:使用 markdown + YAML frontmatter,包含清晰 `description`、必要时声明 `argument-hint` 与 `allowed-tools`。
+- 新增 Agent 遵循 `agent-development`:frontmatter 补齐 `name`、`description`、`model` / `tools` 等字段;复杂触发场景用示例描述;修改后用 validate-agent 规则检查。
+- 新增 Hook 遵循 `hook-development`:插件级 `hooks/hooks.json` 使用 wrapper 格式,即外层包含 `description` 与 `hooks`;命令 hook 使用 `${CLAUDE_PLUGIN_ROOT}`;轻量确定性检查用 command hook,上下文判断才用 prompt hook。
+- 修改插件组件后,必须按 `plugin-validator` 思路做结构校验:manifest、commands、agents、skills、hooks、MCP、README、LICENSE、敏感信息与路径可移植性。
+
+这条优先级高于本 spec 中任何自定义落点建议;如果冲突,以官方 `plugin-dev` 约束为准。
+
+---
+
+## 4. 非目标
+
+本轮不做:
+
+- 不重写 Story System 主链语义。
+- 不引入大规模新 MCP 服务。
+- 不把 37 个题材模板拆成 37 个独立 Skill。
+- 不照搬 Superpowers 的高频 git commit 机制。
+- 不让 hook 承担写作业务。
+- 不在本轮重构 Dashboard 前端信息架构。
+- 不改变已有用户项目的数据格式,除非提供兼容读取。
+
+---
+
+## 5. 目标架构
+
+### 5.1 组件边界
+
+```text
+commands/ 或 Slash Skill 入口
+        ↓
+Skill router(流程、引用路由、失败边界)
+        ↓
+Claude Code Todo(过程约束,由宿主管理)
+        ↓
+Runtime Gates(写前 / 提交前 / 提交后批量校验)
+        ↓
+Agents(context / draft / review / data extract)
+        ↓
+Artifact Validator
+        ↓
+CHAPTER_COMMIT(事实主链)
+        ↓
+Projection Engine(state/index/summary/memory/vector)
+        ↓
+Dashboard / Query / Doctor(只读消费)
+```
+
+### 5.2 写章过程管理
+
+不新增独立 resume / step mark / workflow state。Claude Code 本身已经有 Todo 和会话恢复能力,写章过程的步骤约束交给宿主 Todo 管理。
+
+推荐 Todo 形态:
+
+```text
+[ ] 写前预检与合同刷新
+[ ] context-agent 生成写作任务书
+[ ] 起草正文
+[ ] reviewer 审查
+[ ] blocking issue 裁决 / 定点修复
+[ ] 润色与排版
+[ ] data-agent 提取事实 artifacts
+[ ] chapter-commit 提交事实
+[ ] 验证 projection 与备份
+```
+
+runtime 不维护每一步状态,只提供三个自然边界的批量 gate:
+
+- `prewrite`:写前检查项目根、占位符、Story Runtime、章节合同。
+- `precommit`:提交前检查正文、review、fulfillment、disambiguation、extraction artifacts。
+- `postcommit`:提交后检查 commit、projection、summary、memory、backup。
+
+这样一章最多增加 2-3 次确定性脚本调用,不做每一步打点。
+
+### 5.3 状态感知模型
+
+项目状态分两层:
+
+| 层级 | 负责者 | 持久性 | 用途 |
+|---|---|---|---|
+| 会话内进度 | Claude Code Task / Todo | 会话级 | 约束本轮写作步骤、显示当前正在做什么 |
+| 项目真实状态 | Story System / commit / projection / artifacts | 项目级 | 新对话、resume、doctor 判断下一步 |
+
+不新增独立 workflow state。项目真实状态由 runtime 现场推导:
+
+- `.story-system/commits/*.commit.json` 判断最新 accepted/rejected 章节。
+- `.story-system/MASTER_SETTING.json` 和章节合同判断下一章目标。
+- `.webnovel/tmp/*` artifacts 判断是否已经 review / fulfillment / extraction。
+- `.webnovel/projection_log.jsonl` 或兼容字段判断 projection 是否失败。
+- draft 文件和 chapter artifact 判断是否存在未提交正文。
+
+新增机器可读的项目状态入口,避免占用现有 `webnovel.py status`。当前 `status` 已转发到 `status_reporter.py`,语义是宏观创作健康报告;本 spec 需要的是短状态摘要,因此使用新命令:
+
+```bash
+webnovel.py project-status --format json
+webnovel.py project-status --format summary
+```
+
+示例状态:
+
+```json
+{
+  "schema_version": "webnovel-project-status/v1",
+  "project": "灵石庄",
+  "latest_accepted_chapter": 12,
+  "target_chapter": 13,
+  "phase": "chapter_contract_ready",
+  "blocking": [],
+  "warnings": ["rag_vector_missing"],
+  "next_action": "run /webnovel-write chapter 13"
+}
+```
+
+`phase` 是一个可推导状态,不是 hook 写入的状态机。phase 词表必须只有一个权威来源,建议新增 `project_phase.py`,由 doctor、project-status、write-gate 共同消费。推荐最小集合:
+
+- `no_project`
+- `unknown`
+- `init_scaffolded`
+- `init_ready`
+- `plan_in_progress`
+- `chapter_contract_ready`
+- `draft_in_progress`
+- `ready_to_commit`
+- `chapter_committed`
+- `projection_failed`
+
+### 5.4 Hook 与状态的边界
+
+hook 只读状态、注入短上下文或阻断危险动作:
+
+- `SessionStart`:调用 `project-status --format summary`,在新对话、resume、clear、compact 后告诉 Claude 当前项目写到哪里。
+- `PreToolUse`:在 `webnovel-write` skill 激活期间,阻断绕过 gate 的 commit / projection 写入。
+- `PostToolUse`:可用于把 gate 失败原因补充给 Claude,但不能防止已经发生的副作用。
+
+状态转换只能来自显式 runtime 命令:
+
+- `write-gate --stage prewrite/precommit/postcommit`
+- `chapter-commit`
+- `projections retry/replay`
+- 用户显式裁决 blocking issue
+
+这保证流程推进发生在显式 skill / runtime 命令中,而不是 hook 暗中推进。
+
+---
+
+## 6. Phase 1:`webnovel-doctor` 项目体检入口
+
+### 6.1 目标
+
+新增只读体检命令,作为现有 `preflight` 的上位诊断入口。`preflight` 已经负责 CLI 环境、project_root 与 `story_runtime` 摘要;`doctor` 必须复用或吸收这些检查,不另造一套并行环境检查。
+
+重点解决三类问题:
+
+1. **文件层面**:目录是否规范、关键文件是否缺失、JSON / SQLite / Markdown 等内容是否符合预期。
+2. **系统配置层面**:RAG API / key、Python 依赖、Dashboard 构建产物等运行条件是否完整。
+3. **错误解释与修复建议**:缺失或异常时说明影响范围,并给出可执行修复命令或人工处理建议。
+
+`doctor` 不负责判断一章具体该怎么写,也不替代 `write-gate`。它回答的是:
+
+> 这个书项目和当前插件运行环境是否完整、可读、可运行;如果不完整,哪里坏了,怎么修。
+
+### 6.2 入口
+
+CLI:
+
+```bash
+python -X utf8 webnovel-writer/scripts/webnovel.py --project-root "<PROJECT_ROOT>" doctor --format json
+```
+
+Skill:
+
+```text
+/webnovel-doctor
+```
+
+与现有入口关系:
+
+- `preflight`:保留为快速环境检查和兼容入口。
+- `doctor`:覆盖 `preflight` 的快检能力,并追加阶段感知文件清单、SQLite、RAG、Python 依赖、Dashboard、修复建议。
+- `project-status`:只输出短状态和下一步,不做深度体检。
+- `status`:保留现有 `status_reporter.py` 的宏观创作健康报告语义。
+
+可选后续 hook:
+
+```text
+SessionStart -> 打印 project-status 摘要;异常时提示运行 /webnovel-doctor
+```
+
+### 6.3 模式
+
+默认模式必须只做本地只读检查:
+
+```bash
+webnovel.py doctor --format json
+webnovel.py doctor --format text
+```
+
+可选深度模式才允许做慢检查或外部连通性检查:
+
+```bash
+webnovel.py doctor --deep --format json
+```
+
+可选章节模式用于检查指定章节相关 artifacts:
+
+```bash
+webnovel.py doctor --chapter 13 --format json
+```
+
+默认 `doctor` 禁止:
+
+- 写任何文件。
+- 自动修复。
+- 自动安装 Python / Node 依赖。
+- 自动启动 Dashboard。
+- 默认联网测试 RAG API。
+
+### 6.4 阶段感知的期望文件清单
+
+`doctor` 必须先判断项目当前阶段,再决定“这个阶段应该有哪些文件”。不能用最终态清单检查所有项目。
+
+#### 6.4.1 阶段推导
+
+阶段由共享 `project_phase.py` 现场推导,不写任何状态文件。doctor、project-status、write-gate 必须消费同一个 resolver,避免出现多套 phase 词表:
+
+| phase | 判定依据 | 含义 |
+|---|---|---|
+| `no_project` | project root 无效,或没有 `.webnovel/state.json` | 尚未初始化或未绑定书项目 |
+| `unknown` | 文件状态不足以稳定判断 | 只做低风险检查 |
+| `init_scaffolded` | 有 `.webnovel/state.json`、基础目录、设定集/总纲,但没有 `.story-system/MASTER_SETTING.json` | `webnovel.py init` 刚结束,Story System 尚未生成 |
+| `init_ready` | 有 `.webnovel/state.json`、基础设定集、`大纲/总纲.md`、`.story-system/MASTER_SETTING.json` | init 完成,可进入 plan |
+| `plan_in_progress` | 有 MASTER_SETTING,但卷/章合同不完整 | 正在规划,尚不能直接写章 |
+| `chapter_contract_ready` | 指定章节有 volume / chapter / review 合同 | 可进入写前上下文和起草 |
+| `draft_in_progress` | 指定章节有正文草稿或 `.webnovel/tmp` artifacts | 写章中或审查中 |
+| `ready_to_commit` | review / fulfillment / disambiguation / extraction artifacts 都存在 | 可进入 precommit gate |
+| `chapter_committed` | 指定章节有 commit | 章节已提交,检查 projection |
+| `projection_failed` | latest commit 有 `projection_status.failed:*` | read-model 不可信,需要修复 |
+
+如果无法确定阶段,返回 `phase=unknown`,并只做低风险文件可读性检查。
+
+#### 6.4.2 阶段期望清单
+
+`doctor` 输出必须包含当前阶段的期望清单:
+
+```json
+{
+  "phase": "init_ready",
+  "expected_profile": "after_init",
+  "expected_files": {
+    "required": [
+      ".webnovel/state.json",
+      ".webnovel/summaries/",
+      "设定集/世界观.md",
+      "设定集/力量体系.md",
+      "设定集/主角卡.md",
+      "设定集/反派设计.md",
+      "大纲/总纲.md",
+      ".env.example",
+      ".story-system/MASTER_SETTING.json"
+    ],
+    "conditional": [
+      "设定集/主角组.md",
+      "设定集/女主卡.md"
+    ],
+    "not_expected_yet": [
+      ".story-system/volumes/volume_001.json",
+      ".story-system/chapters/chapter_001.json",
+      ".story-system/reviews/chapter_001.review.json",
+      ".story-system/commits/chapter_001.commit.json",
+      ".webnovel/summaries/chapter_001.md",
+      ".webnovel/memory_scratchpad.json"
+    ]
+  }
+}
+```
+
+`conditional` 文件必须根据 `state.json` 判断。例如:
+
+- `protagonist_structure` 是多主角 / 主角组时,才要求 `设定集/主角组.md`。
+- `heroine_config` 不是无女主时,才要求 `设定集/女主卡.md`。
+- 无金手指项目不要求单独 `金手指设计.md`。
+
+#### 6.4.3 init 刚结束的判定
+
+`webnovel.py init` 刚结束时,合理期望是项目骨架完整,但不要求写作后产物。
+
+必须存在:
+
+```text
+.webnovel/
+.webnovel/backups/
+.webnovel/archive/
+.webnovel/summaries/
+.webnovel/state.json
+设定集/
+设定集/世界观.md
+设定集/力量体系.md
+设定集/主角卡.md
+设定集/反派设计.md
+大纲/
+大纲/总纲.md
+正文/
+审查报告/
+.env.example
+```
+
+如果 `/webnovel-init` 已完成 Story System 初始化,还必须存在:
+
+```text
+.story-system/
+.story-system/MASTER_SETTING.json
+.story-system/anti_patterns.json
+```
+
+init 阶段不应该要求:
+
+```text
+.story-system/volumes/volume_001.json
+.story-system/chapters/chapter_001.json
+.story-system/reviews/chapter_001.review.json
+.story-system/commits/chapter_001.commit.json
+.webnovel/summaries/chapter_001.md
+.webnovel/memory_scratchpad.json
+.webnovel/vectors.db
+```
+
+缺这些只能返回 `skip` 或 `info`,不能作为 warning / blocker。
+
+#### 6.4.4 plan / write / commit 阶段清单
+
+规划完成后,才开始要求:
+
+```text
+.story-system/volumes/volume_001.json
+.story-system/chapters/chapter_001.json
+.story-system/reviews/chapter_001.review.json
+```
+
+写章中,才开始检查:
+
+```text
+.webnovel/tmp/review_results.json
+.webnovel/tmp/fulfillment_result.json
+.webnovel/tmp/disambiguation_result.json
+.webnovel/tmp/extraction_result.json
+```
+
+commit 后,才开始要求:
+
+```text
+.story-system/commits/chapter_001.commit.json
+.webnovel/summaries/chapter_001.md
+.webnovel/index.db
+```
+
+RAG 向量库永远是增强项:
+
+```text
+.webnovel/vectors.db
+```
+
+缺失或为空默认只返回 warning,并说明会降级 BM25;在用户显式要求语义检索或 `--deep --require-rag` 时才可升级为 blocker。
+
+#### 6.4.5 误报控制
+
+`doctor` 的严重级别必须基于“当前阶段 + 用户目标”判断:
+
+| 情况 | 阶段 | 结果 |
+|---|---|---|
+| 缺 commit | `init_ready` | `skip` / `info` |
+| 缺 commit | `ready_to_commit` | `blocker` |
+| 缺 summary | `init_ready` | `skip` / `info` |
+| 缺 summary | `chapter_committed` 且 projection summary=done | `blocker` |
+| 缺 vectors.db | 任意默认模式 | `warning` |
+| 缺 MASTER_SETTING | `init_scaffolded` | `warning`,提示运行 story-system persist |
+| 缺 MASTER_SETTING | `plan_in_progress` 或之后 | `blocker` |
+
+### 6.5 文件 / 数据结构检查
+
+`doctor` 必须把“肉眼难看见”的项目文件和数据库结构变成可读报告。
+
+#### 6.5.1 目录结构
+
+检查:
+
+- project root 是否有效,且不是插件目录本身。
+- `.webnovel/` 是否存在。
+- `.story-system/` 是否存在。
+- `正文/`、`大纲/`、`设定集/` 等书项目目录是否存在。
+- 用户项目文件是否误写入插件目录。
+
+判定:
+
+- project root 无效:`blocker`。
+- 缺 `.webnovel/` 或 `.story-system/`:`blocker` 或 `warning`,取决于是否是刚 init 的项目。
+- 缺正文/大纲/设定集目录:`warning`,并提示初始化或补建。
+
+#### 6.5.2 Story System 主链文件
+
+检查:
+
+- `.story-system/MASTER_SETTING.json` 是否存在、JSON 可读、`meta.contract_type` 是否正确。
+- `volumes/volume_*.json` 是否存在、JSON 可读。
+- `chapters/chapter_*.json` 是否存在、JSON 可读。
+- `reviews/chapter_*.review.json` 是否存在、JSON 可读。
+- `commits/chapter_*.commit.json` 是否存在、JSON 可读。
+- latest commit 的 `meta.status` 是否是 `accepted` / `rejected`。
+- latest commit 的 `provenance.write_fact_role` 是否为 `chapter_commit`。
+
+判定:
+
+- 主链 JSON 读不出来:`blocker`。
+- 已进入写作流程但缺 MASTER_SETTING:`blocker`。
+- latest commit schema 明显不合法:`blocker`。
+- 新项目尚无 commit:`info` 或 `warning`,不能误报为错误。
+
+#### 6.5.3 Projection / Read-model 文件
+
+检查:
+
+- `.webnovel/state.json` 是否存在、JSON 可读、基础字段可解析。
+- `.webnovel/summaries/` 是否存在,最新 accepted 章节是否有 summary。
+- `.webnovel/memory_scratchpad.json` 是否存在、JSON 可读、基础结构可解析。
+- latest commit 的 `projection_status` 是否有 `pending` / `failed:*`。
+
+判定:
+
+- `state.json` 不可读:`blocker`。
+- projection writer failed:`blocker`,因为后续查询和 dashboard 可能不可信。
+- summary / memory 缺失:通常 `warning`,除非对应 projection 标记为 done 但实物不存在。
+
+#### 6.5.4 SQLite 数据库
+
+检查 `.webnovel/index.db`:
+
+- 文件是否存在。
+- SQLite 是否可打开。
+- 关键表是否存在。
+- 关键表行数是否异常。
+- 基础查询是否能执行。
+
+建议首批关键表:
+
+```text
+entities
+relationships
+story_events
+review_metrics
+writing_checklist_scores
+override_ledger
+```
+
+检查 `.webnovel/vectors.db`:
+
+- 文件是否存在。
+- SQLite 是否可打开。
+- `vectors` 表是否存在。
+- vector 行数。
+- `bm25_index` / `doc_stats` 是否存在。
+
+数据库报告必须显式展示表和行数,例如:
+
+```json
+{
+  "id": "db.index.tables",
+  "status": "ok",
+  "severity": "info",
+  "path": ".webnovel/index.db",
+  "tables": {
+    "entities": 128,
+    "relationships": 42,
+    "story_events": 36,
+    "review_metrics": 12
+  }
+}
+```
+
+判定:
+
+- `index.db` 不存在或打不开:`blocker`。
+- `story_events` 缺失:`warning` 或 `blocker`,取决于当前是否已经有 accepted commit。
+- `vectors.db` 缺失:`warning`,RAG 可降级 BM25。
+- `vectors` 行数为 0:`warning`。
+
+#### 6.5.5 Reference / CSV 文件
+
+检查:
+
+- `references/csv/*.csv` 是否存在。
+- 必要 CSV 表头是否符合预期。
+- 题材别名、题材与调性推理、反模式等核心表是否可读。
+- 明显占位符是否残留。
+
+判定:
+
+- 核心 CSV 不可读或表头缺失:`warning`。
+- 会导致 story-system 无法生成 MASTER_SETTING 的缺失:`blocker`。
+
+### 6.6 系统 / 配置检查
+
+#### 6.6.1 Python 依赖
+
+检查:
+
+- 当前 Python 版本。
+- `scripts/requirements.txt` 是否存在。
+- 核心包是否可 import。
+
+首批核心包:
+
+```text
+pydantic
+numpy
+requests
+fastapi
+uvicorn
+watchdog
+```
+
+判定:
+
+- 运行 CLI 必需包缺失:`blocker`。
+- Dashboard 专用包缺失:`warning`,除非用户正在运行 dashboard skill。
+
+#### 6.6.2 RAG 配置
+
+默认模式检查:
+
+- `.env` / 环境变量是否能读到 embedding 配置。
+- embed base_url / model 是否配置。
+- embed api_key 是否存在。
+- rerank base_url / model / api_key 是否存在。
+- `vectors.db` 是否存在且有数据。
+- 当前推断 RAG 模式:`full` / `embed_only` / `bm25_only`。
+
+`--deep` 模式才检查:
+
+- embed API 是否真实可调用。
+- rerank API 是否真实可调用。
+- API 返回维度是否与已有 vectors 兼容。
+
+判定:
+
+- 缺 RAG key:`warning`,必须明确说明会降级到 BM25。
+- API 连通失败:`warning` 或 `blocker`,取决于用户是否要求必须语义检索。
+- base_url / model 明显空缺:`warning`。
+
+#### 6.6.3 Dashboard / Node
+
+检查:
+
+- `dashboard/frontend/dist/index.html` 是否存在。
+- dashboard 后端模块是否能 import。
+- `dashboard/requirements.txt` 是否存在。
+- `dashboard/frontend/package.json` 是否存在。
+
+默认不检查:
+
+- 不自动 `npm install`。
+- 不自动启动服务。
+- 不默认检查 localhost 端口。
+
+判定:
+
+- dist 缺失:`warning`,提示重新 build。
+- FastAPI 依赖缺失:`warning`。
+
+### 6.7 输出格式
+
+每条检查必须包含:
+
+- `id`:稳定错误码,方便测试和 UI 展示。
+- `status`:`ok` / `fail` / `warn` / `skip`。
+- `severity`:`blocker` / `warning` / `info`。
+- `path`:相关文件路径,没有则为空。
+- `expected`:预期状态。
+- `actual`:实际状态。
+- `impact`:对用户有什么影响。
+- `repair`:修复命令或人工修复建议。
+
+```json
+{
+  "ok": false,
+  "project_root": "...",
+  "mode": "default",
+  "phase": "chapter_committed",
+  "expected_profile": "after_commit",
+  "blocking_count": 1,
+  "warning_count": 2,
+  "expected_files": {
+    "required": [".webnovel/state.json", ".story-system/commits/chapter_001.commit.json"],
+    "not_expected_yet": []
+  },
+  "checks": [
+    {
+      "id": "db.index.missing_table",
+      "status": "fail",
+      "severity": "blocker",
+      "path": ".webnovel/index.db",
+      "expected": "table story_events exists",
+      "actual": "table missing",
+      "impact": "无法确认 accepted commit 的事件链是否完成投影",
+      "repair": {
+        "command": "webnovel.py projections replay --from 1 --to latest --writers index",
+        "manual": "如果 replay 尚未实现,先重新执行最近章节的 chapter-commit 或从备份恢复 index.db"
+      }
+    }
+  ],
+  "recommended_actions": [
+    {
+      "command": "webnovel.py rag stats",
+      "reason": "vectors.db missing; semantic retrieval will fall back to BM25",
+      "severity": "warning"
+    }
+  ]
+}
+```
+
+### 6.8 错误码命名
+
+错误码按域划分:
+
+```text
+project.root.invalid
+project.phase.unknown
+project.expected_file.missing
+project.structure.missing_dir
+story.master.missing
+story.commit.invalid_json
+story.commit.invalid_status
+projection.status.failed
+projection.file.missing
+db.index.unreadable
+db.index.missing_table
+db.vector.empty
+rag.embed.key_missing
+rag.embed.api_unreachable
+python.import_missing
+dashboard.dist_missing
+reference.csv.invalid_header
+artifact.schema_error
+```
+
+### 6.9 文件落点
+
+- `webnovel-writer/scripts/data_modules/doctor.py`
+- `webnovel-writer/scripts/data_modules/project_phase.py`
+- `webnovel-writer/scripts/data_modules/project_status.py`
+- `webnovel-writer/scripts/data_modules/webnovel.py`
+- `webnovel-writer/skills/webnovel-doctor/SKILL.md`
+- `webnovel-writer/scripts/data_modules/tests/test_doctor.py`
+- `webnovel-writer/scripts/data_modules/tests/test_project_phase.py`
+- `webnovel-writer/scripts/data_modules/tests/test_project_status.py`
+- `docs/guides/commands.md`
+
+### 6.10 验收
+
+- 空项目返回 `ok=false`,但不写任何文件。
+- init 刚结束时能识别 `phase=init_scaffolded` 或 `phase=init_ready`,并返回该阶段的 `expected_files`。
+- init 刚结束时缺 commit / summary / memory / vectors.db 不得返回 blocker。
+- init 刚结束时缺 `state.json`、`设定集/世界观.md`、`大纲/总纲.md` 必须返回 blocker 或 warning,并给出补救命令。
+- `MASTER_SETTING.json` 在 `init_scaffolded` 阶段缺失是 warning,在 plan/write 阶段缺失是 blocker。
+- 正常项目返回 `ok=true`,并显示 `index.db` / `vectors.db` 的关键表和行数。
+- 缺 `state.json` 返回 `project.structure` 或 `projection.file` 类 blocker。
+- `index.db` 缺关键表时返回稳定错误码、影响说明和修复建议。
+- `vectors.db` 缺失或为空时返回 warning,并明确说明 RAG 会降级到 BM25。
+- 缺 RAG key 时返回 warning,不阻断普通写作。
+- Python 必需包缺失时返回 blocker,并提示安装 `scripts/requirements.txt`。
+- Dashboard dist 缺失时返回 warning,并提示 build 命令。
+- latest commit projection failed 时返回 actionable command。
+- 默认模式不联网、不安装依赖、不启动服务、不写文件。
+- `--deep` 模式可进行 RAG API ping,但必须明确标记为 deep check。
+- `preflight` 仍可运行;其结果与 doctor 的快检部分不冲突。
+- 所有中文路径和中文文件读取在 Windows 下使用 UTF-8,不因默认 GBK 失败。
+
+---
+
+## 7. Phase 2:章节 Runtime Gates
+
+### 7.1 目标
+
+不重造一套 workflow/resume 系统。把 `/webnovel-write` 中最容易出错的关键边界下沉为批量校验 gate,过程顺序由 Claude Code Todo 约束。
+
+实施顺序上,Runtime Gates 必须依赖 Artifact Validator 的统一错误语义;本节描述 gate 设计,不代表先于 validator 开工。
+
+### 7.2 新增模块
+
+建议新增 gate 外壳,但 `prewrite` 必须包装或迁移现有 `PrewriteValidator`,不得重写一套占位符和合同判断逻辑:
+
+```text
+webnovel-writer/scripts/data_modules/write_gates/
+  __init__.py
+  prewrite.py
+  precommit.py
+  postcommit.py
+```
+
+已有复用点:
+
+- `webnovel-writer/scripts/data_modules/prewrite_validator.py`
+- `webnovel-writer/scripts/data_modules/tests/test_prewrite_validator.py`
+
+### 7.3 Gate 设计
+
+不写 `.workflow.json`,不维护 step state。每次 gate 根据现有项目文件和 artifacts 现场计算结果。
+
+统一输出:
+
+```json
+{
+  "schema_version": "write-gate/v1",
+  "chapter": 12,
+  "stage": "precommit",
+  "ok": false,
+  "blocking": [
+    {
+      "type": "pending_disambiguation",
+      "detail": "disambiguation_result.pending is not empty"
+    }
+  ],
+  "warnings": [],
+  "artifacts": {
+    "review_result": ".webnovel/tmp/review_results.json",
+    "fulfillment_result": ".webnovel/tmp/fulfillment_result.json",
+    "disambiguation_result": ".webnovel/tmp/disambiguation_result.json",
+    "extraction_result": ".webnovel/tmp/extraction_result.json"
+  }
+}
+```
+
+### 7.4 Gate 职责
+
+runtime gate 负责:
+
+- 校验必要文件存在。
+- 校验 JSON schema。
+- prewrite 阶段复用 `PrewriteValidator`。
+- 判定 blocking issue。
+- 判定是否允许进入下一自然阶段。
+- 输出明确的失败原因和建议命令。
+
+runtime gate 不负责:
+
+- 代替 LLM 起草正文。
+- 代替 Agent 做审查。
+- 自动决定用户裁决。
+- 记录每一步进度。
+- 替代 Claude Code Todo / 会话恢复能力。
+
+### 7.5 CLI 子命令
+
+```bash
+webnovel.py write-gate --chapter N --stage prewrite --format json
+webnovel.py write-gate --chapter N --stage precommit --format json
+webnovel.py write-gate --chapter N --stage postcommit --format json
+```
+
+### 7.6 Skill 改动
+
+`webnovel-write/SKILL.md` 改为:
+
+1. 使用 Claude Code Todo 建立本章流程清单。
+2. 调 `write-gate --stage prewrite`,通过后才写。
+3. 调 context-agent。
+4. 起草正文。
+5. 调 reviewer。
+6. blocking issue 由 Todo 记录并裁决 / 定点修复。
+7. 润色后调 data-agent。
+8. 调 `write-gate --stage precommit`,通过后才提交。
+9. 调 chapter-commit。
+10. 调 `write-gate --stage postcommit`,通过后才宣布完成。
+
+### 7.7 验收
+
+- 缺 `review_results.json` 时不允许进入 commit。
+- reviewer 有 blocking issue 时 `precommit.ok=false`。
+- disambiguation pending 非空时 `precommit.ok=false`。
+- projection failed 时 `postcommit.ok=false`。
+- gate 调用次数控制在每章 2-3 次,不做逐步 mark。
+
+---
+
+## 8. Phase 3:Artifact Validator
+
+### 8.1 目标
+
+统一校验所有 agent 产物,避免字段名漂移、包错外层、缺 required 字段。
+
+### 8.2 校验对象
+
+- `review_results.json`
+- `fulfillment_result.json`
+- `disambiguation_result.json`
+- `extraction_result.json`
+- `chapter_XXX.commit.json`
+- `projection_status`
+
+权威 schema 来源:
+
+- `review_results.json`、`fulfillment_result.json`、`disambiguation_result.json`、`extraction_result.json` 默认以 `chapter_commit_schema.py` 中 commit 所需的 Pydantic model 为准。
+- `review_schema.py` 和 `entity_linker.py` 中同名 / 近名模型只作为上游工具局部模型,不作为 commit artifact 的最终权威。
+- 如需兼容上游局部模型输出,必须在 `artifact_validator.py` 显式做 normalize,并在输出中标注兼容来源。
+
+### 8.3 输出错误分类
+
+```text
+schema_error
+missing_artifact
+blocking_review
+missed_outline_node
+pending_disambiguation
+commit_rejected
+projection_failure
+unsafe_project_root
+placeholder_blocker
+```
+
+### 8.4 文件落点
+
+- `webnovel-writer/scripts/data_modules/artifact_validator.py`
+- `webnovel-writer/scripts/data_modules/tests/test_artifact_validator.py`
+- `webnovel-writer/scripts/data_modules/write_gates/precommit.py`
+
+### 8.5 验收
+
+- `extraction_result.json` 外层包成 `{"extraction": ...}` 时返回 schema_error。
+- `state_deltas` 使用旧字段名时能兼容或给出明确诊断。
+- `disambiguation_result.pending` 非空时阻断 commit。
+- `fulfillment_result.missed_nodes` 非空时阻断 accepted commit。
+- `ReviewResult` / `DisambiguationResult` 等同名模型不再各自漂移,validator 明确以 commit artifact schema 为准。
+
+---
+
+## 9. Phase 4:Commit 不可变与 Projection Log 外置
+
+### 9.1 当前问题
+
+当前 `ChapterCommitService` 会:
+
+1. build commit。
+2. persist commit。
+3. apply projections。
+4. 将 `projection_status` 写回 commit。
+
+这让 commit 同时承担“事实记录”和“投影执行日志”两个职责。
+
+### 9.2 目标
+
+将事实与投影执行状态拆开:
+
+```text
+.story-system/commits/chapter_012.commit.json     # 不可变事实
+.webnovel/projection_log.jsonl                    # 投影执行日志
+index.db.projection_runs                           # 可查询投影状态
+```
+
+### 9.3 迁移策略
+
+Phase 4 不强制立刻删除 commit 内 `projection_status`,采用双写过渡:
+
+1. 保留 commit 内 projection_status 兼容 Dashboard。
+2. 新增 projection log。
+3. Dashboard / doctor 优先读取 projection log。
+4. 后续版本再将 commit 内 projection_status 标记 deprecated。
+
+### 9.4 Projection Run Schema
+
+```json
+{
+  "run_id": "ch012-20260604T102233",
+  "chapter": 12,
+  "commit_path": ".story-system/commits/chapter_012.commit.json",
+  "commit_hash": "sha256:...",
+  "writer": "memory",
+  "status": "done",
+  "started_at": "...",
+  "finished_at": "...",
+  "error": "",
+  "retry_of": ""
+}
+```
+
+### 9.5 文件落点
+
+- `webnovel-writer/scripts/data_modules/projection_log.py`
+- `webnovel-writer/scripts/data_modules/chapter_commit_service.py`
+- `webnovel-writer/scripts/data_modules/tests/test_projection_log.py`
+- `webnovel-writer/dashboard/app.py`
+- `webnovel-writer/scripts/data_modules/story_runtime_health.py`
+
+### 9.6 验收
+
+- 每个 writer 执行后都有 projection log。
+- 单 writer failed 不影响其他 writer 记录。
+- doctor 能指出 failed writer 和建议重跑命令。
+- commit 文件 hash 在 projection log 中可追溯。
+
+---
+
+## 10. Phase 5:Projection Replay / Retry
+
+### 10.1 目标
+
+投影失败时可以只补跑失败 writer,尤其是 vector / RAG 等外部依赖。
+
+### 10.2 CLI
+
+```bash
+webnovel.py projections status --chapter N
+webnovel.py projections retry --chapter N --writer vector
+webnovel.py projections retry-failed --chapter N
+webnovel.py projections replay --from 1 --to 20 --writers state,index,summary
+```
+
+### 10.3 约束
+
+- replay 只能读取 accepted commit。
+- rejected commit 只允许 state writer 更新状态。
+- writer 必须幂等。
+- retry 不得修改 commit 事实内容。
+
+### 10.4 文件落点
+
+- `webnovel-writer/scripts/data_modules/projection_runner.py`
+- `webnovel-writer/scripts/data_modules/event_projection_router.py`
+- projection writer 幂等性测试
+
+### 10.5 验收
+
+- 删除 `summaries/chapter_012.md` 后 retry summary 可恢复。
+- vector API key 缺失时 vector failed,其余 writer done。
+- 配置 key 后 retry vector 只补 vector。
+- replay 1-5 后 state/index/summary 与 commit 链一致。
+
+---
+
+## 11. Phase 6:Skill / Agent 契约补强
+
+### 11.1 Skill Frontmatter
+
+7 个现有 Skill 的 `description` 要从单句说明升级为召回规则:
+
+- 何时使用。
+- 典型触发词。
+- 不适用场景。
+- 是否有副作用。
+
+示例:
+
+```yaml
+description: Use when the user wants to draft, continue, rewrite, or commit a numbered webnovel chapter. Runs the full context -> draft -> review -> polish -> fact extraction -> chapter commit workflow. Do not use for pure status queries, project initialization, or dashboard-only requests.
+```
+
+### 11.2 Agent Frontmatter
+
+所有 agent 补齐:
+
+- `name`
+- `description`
+- `tools`
+- `model` 可选
+- `output_schema`
+- `failure_statuses`
+
+### 11.3 Agent 分工调整
+
+现有:
+
+- `context-agent`
+- `reviewer`
+- `data-agent`
+- `deconstruction-agent`
+
+建议新增或拆分:
+
+- `continuity-reviewer`:设定 / 时间线 / 人物状态 / 伏笔合规。
+- `style-reviewer`:文风 / AI 味 / 句式重复 / 排版。
+- `reader-pull-reviewer`:爽点 / 钩子 / 微兑现 / 追读力。
+
+短期可以先不新增文件,而是在 `reviewer` 输出 schema 中拆维度;中期再拆 agent。
+
+### 11.4 验收
+
+- prompt integrity 测试确认所有 Skill 有足够长的 description。
+- agent 输出 schema 可被 artifact validator 校验。
+- data-agent 文档中仍明确“不直接写 state/index/summaries/memory”。
+
+---
+
+## 12. Phase 7:Behavior Evals
+
+### 12.1 目标
+
+学习 Superpowers 的 headless 行为测试思路,补上“插件是否按协议执行”的验证层。
+
+### 12.2 Eval 类型
+
+新增:
+
+```text
+evals/
+  skill-triggering/
+  workflow-behavior/
+  agent-output-schema/
+  continuity-conflict/
+  memory-commit/
+```
+
+### 12.3 首批用例
+
+| Eval | 目标 |
+|---|---|
+| init_project_safety | 不在插件目录生成项目,不污染 canon |
+| plan_outputs_executable_chapter_tasks | 章纲包含目标情绪、人物变化、伏笔、禁写事项 |
+| write_blocks_on_review_blocking_issue | blocking issue 不进入 commit |
+| data_agent_never_writes_projection | data-agent 只产出 artifacts |
+| commit_drives_projection | accepted commit 后 projection writer 被触发 |
+| query_falls_back_explicitly | 主链缺失时 query 明确说明 fallback |
+| dashboard_readonly | Dashboard API 不提供写接口 |
+
+### 12.4 Runner
+
+先做轻量 runner:
+
+```bash
+python webnovel-writer/scripts/run_behavior_evals.py --case write_blocks_on_review_blocking_issue
+```
+
+如果本地没有 Claude Code CLI,则 eval 可跳过 transcript 测试,只跑 artifact fixture 测试。
+
+### 12.5 验收
+
+- 每个 Skill 至少有 1 个 eval。
+- `webnovel-write` 至少覆盖成功链路和 blocking 链路。
+- eval 输出 JSON 报告,包含 pass/fail/reason/artifacts。
+
+---
+
+## 13. Phase 8:Manifest / Marketplace / 发布治理
+
+### 13.1 目标
+
+防止插件元数据、README、marketplace、version 之间漂移。
+
+### 13.2 校验脚本
+
+新增:
+
+```bash
+python webnovel-writer/scripts/validate_plugin_package.py
+```
+
+检查:
+
+- 根 `.claude-plugin/marketplace.json` 存在。
+- 插件 `.claude-plugin/plugin.json` 存在。
+- marketplace version 与 plugin.json version 一致。
+- README / 现有 CI 使用的版本位置与 plugin.json 一致;不得新增一套与既有 Plugin Version Check 冲突的 README 版本规则。
+- 每个 `skills/*/SKILL.md` 有 frontmatter。
+- 每个 `agents/*.md` 有 frontmatter。
+- LICENSE 存在。
+- Dashboard dist 存在。
+- `scripts/requirements.txt` 与根 `requirements.txt` 可解析。
+- docs 命令表与实际 Skill 名称一致。
+
+### 13.3 可选 manifest 增强
+
+如 Claude Code manifest 支持,可补:
+
+- `commands`
+- `agents`
+- `hooks`
+- `mcpServers`
+- user config schema
+- screenshots / assets
+
+如果当前宿主不需要显式声明,则保持默认目录发现,避免过度配置。
+
+### 13.4 验收
+
+- clean clone 后 validate 通过。
+- 修改 version 任一处导致 validate 失败。
+- 删除一个 Skill frontmatter 导致 validate 失败。
+- 版本校验复用或对齐现有 CI 规则,不与 README 版本表 / badge 检查互相打架。
+
+---
+
+## 14. Phase 9:轻量 SessionStart Hook(可选)
+
+### 14.1 目标
+
+新增可选 hook,在会话启动、resume、clear、compact 时提示项目状态。该 hook 是状态观察器,不是状态机。
+
+### 14.2 输出
+
+```text
+Webnovel Writer initialized.
+  project: 灵石庄
+  story runtime: mainline ready, latest chapter 12 accepted
+  projections: 4 done, 1 failed(vector)
+  rag: BM25 fallback; EMBED_API_KEY missing
+  next: run /webnovel-doctor for details
+```
+
+### 14.3 约束
+
+- 只读。
+- 不安装依赖。
+- 不写任何文件。
+- 输出不超过 8 行。
+- 正常状态下只输出摘要,不输出完整 JSON。
+- 失败时给出一个下一步命令,不展开长诊断。
+- 可通过环境变量关闭:
+
+```text
+WEBNOVEL_DISABLE_SESSION_HOOK=1
+```
+
+### 14.4 文件落点
+
+- `webnovel-writer/hooks/hooks.json`
+- `webnovel-writer/hooks/session_start.py`
+- `webnovel-writer/.claude-plugin/plugin.json` 或默认 hook 发现路径
+- `docs/operations/operations.md`
+
+### 14.5 验收
+
+- 无项目根时不报错,只提示未绑定项目。
+- 有项目根时调用 `project-status --format summary` 或 doctor summary。
+- 设置 disable env 后无输出。
+- resume 后能刷新 latest chapter / projection 状态。
+- 输出不会超过 1000 字符。
+
+### 14.6 Skill-scoped 预检 Hook(可选)
+
+对 `/webnovel-write` 这类高风险 skill,可以在 skill frontmatter 中挂轻量 hook:
+
+- `PreToolUse(Bash)`:对直接运行 `chapter_commit.py`、`webnovel.py chapter-commit` 或 projection 写入命令做 best-effort 提醒 / 兜底阻断。Bash 字符串解析不能作为唯一可靠保证,真正的强保证必须在 runtime gate 和 commit 入口中实现。
+- `PreToolUse(Write|Edit)`:如果目标路径是 `.story-system/` commit、`.webnovel/state.json`、`index.db`、`memory_scratchpad.json` 等 projection 产物,则要求走 runtime 命令。
+- hook 通过时必须静默;只在阻断时返回简短原因。
+
+不建议把所有固定预检都放进 hook。推荐分层:
+
+| 预检类型 | 放在哪里 |
+|---|---|
+| 新会话状态摘要 | plugin-level `SessionStart` hook |
+| 是否能开始写本章 | `write-gate --stage prewrite` |
+| 是否能提交本章 | `write-gate --stage precommit` |
+| 是否能宣布完成 | `write-gate --stage postcommit` |
+| 禁止绕过 runtime 写主链 | skill-scoped `PreToolUse` hook |
+| 复杂修复建议 | `/webnovel-doctor` skill |
+
+## 15. 推荐实施顺序
+
+1. `project_phase` + `project-status` + `webnovel-doctor`:先建立统一阶段推导、短状态和只读自检基本盘。
+2. `Artifact Validator`:统一错误语义。
+3. `Runtime Gates`:用写前 / 提交前 / 提交后批量校验约束关键边界,其中 prewrite 复用 `PrewriteValidator`。
+4. `Projection Log`:事实与投影日志解耦。
+5. `Projection Retry / Replay`:补恢复能力。
+6. `Skill / Agent 契约补强`:降低 prompt 漂移。
+7. `Behavior Evals`:证明插件协议有效。
+8. `Plugin Package Validator`:发布治理。
+9. 可选 `SessionStart Hook`:只读状态提示。
+10. 可选 `Skill-scoped PreToolUse Hook`:阻断绕过 runtime 的危险写入。
+
+---
+
+## 16. 验收总表
+
+| 能力 | 验收标准 |
+|---|---|
+| Doctor | 能只读报告目录/文件/数据库完整性、RAG/Python/Dashboard 配置,并给出修复建议 |
+| Project Status | 能从主链和 artifacts 推导当前章节阶段,不占用既有 `status_reporter.py` 语义,不写 workflow state |
+| Runtime Gates | `/webnovel-write` 在写前、提交前、提交后三个自然边界有批量校验 |
+| Validator | agent 产物 schema 漂移能被统一诊断 |
+| Commit | commit 事实与 projection log 可分离追溯 |
+| Replay | vector/summary 等投影失败后可单独 retry |
+| Skills | 7 个 Skill description 足够路由,长知识按需加载 |
+| Agents | agent 有工具范围、输出 schema、失败状态 |
+| Evals | 每个 Skill 至少 1 个行为 eval |
+| Package | manifest / marketplace / README / version 可校验 |
+| Hook | 如果启用,SessionStart 只读短输出,PreToolUse 只做危险动作阻断 |
+
+---
+
+## 17. 风险
+
+### 17.1 过度工程化
+
+风险:为了学习优秀插件,把当前系统拆得太碎。
+控制:先做 doctor / validator / runtime gates 三个高收益模块,不急着拆 37 个题材 Skill。
+
+### 17.2 Hook 副作用
+
+风险:hook 自动执行导致用户不信任插件。
+控制:SessionStart hook 只读,PreToolUse hook 只阻断危险动作;所有修复和推进必须由 skill / runtime 显式触发。
+
+### 17.3 Hook 状态机漂移
+
+风险:如果 hook 自己写状态,可能与 commit / projection / Todo 产生三套真相。
+控制:状态由共享 `project_phase` / `project-status` 现场推导;hook 不写状态;流程推进只由显式 skill / runtime 命令触发。
+
+### 17.4 Commit 迁移破坏 Dashboard
+
+风险:projection_status 外置后 Dashboard 读不到状态。
+控制:先双写,Dashboard 优先读新 projection log,旧字段保留一个版本周期。
+
+### 17.5 Eval 成本高
+
+风险:headless Claude 行为 eval 慢且贵。
+控制:分 fast fixture eval 和 slow transcript eval;CI 默认只跑 fast。
+
+### 17.6 Skill 触发变化
+
+风险:description 改长后触发行为变化。
+控制:增加 skill-triggering eval,先验证再发布。
+
+---
+
+## 18. 不变量
+
+无论如何重构,必须保持:
+
+1. `.story-system/` 是主链真源。
+2. accepted `CHAPTER_COMMIT` 是写后事实入口。
+3. `.webnovel/state.json`、`index.db`、`summaries/`、`memory_scratchpad.json` 是 projection / read-model。
+4. `data-agent` 不直接写 projection。
+5. Dashboard 默认只读。
+6. RAG key 缺失必须可降级到 BM25。
+7. 用户项目文件不能写到插件目录。
+8. hook 不是项目状态真源。
+9. `webnovel.py status` 继续保留宏观创作健康报告语义,短状态使用 `project-status`。
+
+---
+
+## 19. 第一批可开工任务
+
+1. 新增 `project_phase.py`,统一 doctor / project-status / gates 的 phase 推导。
+2. 新增 `project_status.py`,注册 `project-status` 子命令,保留现有 `status` 转发到 `status_reporter.py`。
+3. 新增 `doctor.py`,复用现有 `preflight` / `build_story_runtime_health()`。
+4. 在统一 CLI 注册 `doctor` 子命令。
+5. 新增 `/webnovel-doctor` Skill。
+6. 新增 `artifact_validator.py`,先包装 `chapter_commit_schema.py` 中的 commit artifact Pydantic schema。
+7. 给 `webnovel-write` 的四类 agent artifact 增加 validator 测试 fixture。
+8. 新增 `write_gates/prewrite.py`、`write_gates/precommit.py`、`write_gates/postcommit.py`,其中 prewrite 包装 `PrewriteValidator`。
+9. 修改 `webnovel-write/SKILL.md`,开始引用 `write-gate --stage prewrite/precommit/postcommit`,过程管理仍使用 Claude Code Todo。
+10. 先审计 5 个 projection writer 的幂等性,再新增 `projection_log.py`。
+11. 给 7 个 Skill 补 description。
+12. 新增 `validate_plugin_package.py`,先对齐现有版本 CI,再校验 frontmatter / LICENSE / dist。
+13. 新增可选 SessionStart hook,只注入 project-status summary。
+14. 新增可选 skill-scoped PreToolUse hook,作为 best-effort 兜底提醒 / 阻断。
+
+---
+
+## 20. 最终判断
+
+`webnovel-writer` 当前最大短板不是知识库不足,也不是题材模板不够,而是:
+
+> 关键流程仍有一部分靠 Skill 文档和 Agent 遵守协议来保证。
+
+本 spec 的核心就是把这些协议逐步变成 runtime 可验证机制:
+
+- doctor 负责知道项目文件、数据库和系统配置是否完整可用;
+- project-status 负责用统一 phase resolver 知道项目现在写到哪里;
+- runtime gates 负责知道关键边界是否可继续;
+- validator 负责知道产物是否可信;
+- projection log 负责知道 read-model 是否同步;
+- eval 负责证明 agent 真的按协议执行;
+- package validator 负责发布物没有漂移。
+
+做到这些,`webnovel-writer` 才会真正具备优秀 Claude Code 插件的工程稳定性。

+ 25 - 1
docs/guides/commands.md

@@ -72,6 +72,21 @@
 - 默认只读,不会修改项目文件
 - 前端构建产物已随插件发布,无需本地 `npm build`
 
+### `/webnovel-doctor [--chapter N] [--deep]`
+
+只读体检当前网文项目,检查阶段应有文件、JSON、SQLite、RAG 配置、Python 依赖与 Dashboard 产物,并给出影响和修复建议。
+
+```bash
+/webnovel-doctor
+/webnovel-doctor --chapter 12
+/webnovel-doctor --deep
+```
+
+说明:
+
+- 不写入项目,不安装依赖,不启动服务
+- 会先判断当前项目阶段,init 刚结束时不会按终态项目误报
+
 ## 统一 CLI(命令行使用)
 
 所有 CLI 命令的入口都是 `webnovel.py`,格式:
@@ -115,6 +130,10 @@ python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJE
 |--------|------|
 | `where` | 打印当前解析出的项目根目录 |
 | `preflight` | 校验 CLI 环境、脚本路径和项目根是否可用 |
+| `project-status` | 输出机器可读短状态(phase、目标章节、下一步),不占用旧 `status` |
+| `doctor` | 阶段感知项目体检(目录、文件、DB、RAG、依赖、Dashboard) |
+| `write-gate` | 写章自然边界校验(`prewrite` / `precommit` / `postcommit`) |
+| `projections` | 从已有 commit 补跑或重放 projection |
 | `use <路径>` | 绑定当前工作区使用的书项目 |
 
 ### 数据模块子命令
@@ -133,7 +152,7 @@ python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJE
 
 | 子命令 | 说明 |
 |--------|------|
-| `status` | 健康报告(`--focus all` / `--focus urgency`) |
+| `status` | 宏观创作健康报告(`--focus all` / `--focus urgency`),仍转发到 `status_reporter.py` |
 | `update-state` | 手动更新状态 |
 | `backup` | 备份管理 |
 | `archive` | 归档管理 |
@@ -164,6 +183,11 @@ python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJE
 | `story-system "<题材>" --persist` | 写入合同种子(`MASTER_SETTING.json` 等) |
 | `story-system "<题材>" --emit-runtime-contracts --chapter N` | 生成运行时合同 + 写前校验 |
 | `chapter-commit --chapter N` | 提交章节 commit(可附带 review/fulfillment/disambiguation/extraction 结果) |
+| `write-gate --chapter N --stage prewrite` | 写前检查项目阶段、Story System 合同和占位符 |
+| `write-gate --chapter N --stage precommit` | 提交前检查正文和四类 commit artifacts |
+| `write-gate --chapter N --stage postcommit` | 提交后检查 commit 与 projection 状态 |
+| `projections retry --chapter N` | 基于已有 commit 补跑单章 projection |
+| `projections replay --from-chapter A --to-chapter B` | 按章节范围重放 projection |
 | `story-events --chapter N` | 查询指定章节事件 |
 | `story-events --health` | 事件链健康检查 |
 | `memory-contract` | 记忆合同管理 |

+ 50 - 3
docs/operations/operations.md

@@ -41,6 +41,7 @@ project-root/
 │   ├── state.json        # 项目状态
 │   ├── index.db          # SQLite 索引(实体/关系/章节数据)
 │   ├── vectors.db        # 向量索引
+│   ├── projection_log.jsonl # 投影执行日志
 │   ├── summaries/        # 章节摘要
 │   ├── backups/          # 自动备份
 │   └── archive/          # 归档
@@ -63,9 +64,10 @@ project-root/
 
 ```text
 ${CLAUDE_PLUGIN_ROOT}/
-├── skills/       # 7 个 Skill 命令定义
-├── agents/       # 3 个 Agent 定义
+├── skills/       # 8 个 Skill 命令定义
+├── agents/       # 4 个 Agent 定义
 ├── scripts/      # Python 脚本与数据模块
+├── hooks/        # Claude Code 会话钩子
 ├── references/   # 参考文档(题材画像、追读力分类法等)
 ├── templates/    # 初始化模板
 ├── genres/       # 精调题材配置
@@ -86,12 +88,28 @@ ${CLAUDE_HOME:-~/.claude}/webnovel-writer/workspaces.json
 
 ```bash
 python -X utf8 "${CLAUDE_PLUGIN_ROOT}/scripts/webnovel.py" --project-root "${WORKSPACE_ROOT}" preflight
+python -X utf8 "${CLAUDE_PLUGIN_ROOT}/scripts/webnovel.py" --project-root "${WORKSPACE_ROOT}" project-status --format summary
+python -X utf8 "${CLAUDE_PLUGIN_ROOT}/scripts/webnovel.py" --project-root "${WORKSPACE_ROOT}" doctor --format text
 ```
 
-检查项:插件脚本路径 / 项目根是否可解析 / Skill 目录是否存在。
+`preflight` 是快速检查,`project-status` 给短状态和下一步,`doctor` 是阶段感知体检。
+
+检查项:插件脚本路径 / 项目根是否可解析 / Skill 目录是否存在 / 阶段应有文件 / JSON / SQLite / RAG 配置 / Python 依赖 / Dashboard 产物。
 
 若 `story_runtime.mainline_ready=false`,说明当前项目仍在 legacy fallback 或 commit 主链不完整。
 
+### 写章关卡
+
+```bash
+python -X utf8 "${CLAUDE_PLUGIN_ROOT}/scripts/webnovel.py" --project-root "${PROJECT_ROOT}" write-gate --chapter 12 --stage prewrite --format text
+python -X utf8 "${CLAUDE_PLUGIN_ROOT}/scripts/webnovel.py" --project-root "${PROJECT_ROOT}" write-gate --chapter 12 --stage precommit --format text
+python -X utf8 "${CLAUDE_PLUGIN_ROOT}/scripts/webnovel.py" --project-root "${PROJECT_ROOT}" write-gate --chapter 12 --stage postcommit --format text
+```
+
+- `prewrite`:检查项目阶段、runtime contract、占位符和写前必要文件。
+- `precommit`:检查正文和 review / fulfillment / disambiguation / extraction 四类提交产物。
+- `postcommit`:检查 commit 和 projection 状态。
+
 ### 索引重建
 
 ```bash
@@ -106,6 +124,8 @@ python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" status -- -
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" status -- --focus urgency
 ```
 
+`status` 保留宏观创作健康报告语义;需要机器可读短状态时使用 `project-status`。
+
 ### 向量重建
 
 ```bash
@@ -113,11 +133,38 @@ python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" rag index-c
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" rag stats
 ```
 
+### 投影补跑
+
+```bash
+python -X utf8 "${CLAUDE_PLUGIN_ROOT}/scripts/webnovel.py" --project-root "${PROJECT_ROOT}" projections retry --chapter 12 --format text
+python -X utf8 "${CLAUDE_PLUGIN_ROOT}/scripts/webnovel.py" --project-root "${PROJECT_ROOT}" projections replay --from-chapter 1 --to-chapter 12 --format text
+```
+
+投影补跑只从已有 `.story-system/commits/*.commit.json` 读取事实,并重新生成 `.webnovel/state.json`、`index.db`、`summaries/`、`memory_scratchpad.json`、`vectors.db` 等 read-model。每次执行会追加 `.webnovel/projection_log.jsonl`。
+
 ### 测试
 
 ```bash
 pwsh "${CLAUDE_PLUGIN_ROOT}/scripts/run_tests.ps1" -Mode smoke
 pwsh "${CLAUDE_PLUGIN_ROOT}/scripts/run_tests.ps1" -Mode full
+python -X utf8 "${CLAUDE_PLUGIN_ROOT}/scripts/run_behavior_evals.py" --format text
+python -X utf8 "${CLAUDE_PLUGIN_ROOT}/scripts/validate_plugin_package.py" --format text
+```
+
+`run_behavior_evals.py` 是快速行为契约检查;`validate_plugin_package.py` 按 plugin-dev 思路检查 manifest、Skill / Agent frontmatter、hooks wrapper、README 版本和路径可移植性。
+
+### Hook 开关
+
+插件级 hook 默认很轻:
+
+- `SessionStart`:只运行 `project-status --format summary`,不写文件、不启动服务。
+- `PreToolUse`:对直接写主链 / read-model 文件和绕过 runtime 的危险命令做兜底阻断。
+
+需要临时关闭时设置环境变量:
+
+```bash
+WEBNOVEL_DISABLE_SESSION_STATUS_HOOK=1
+WEBNOVEL_DISABLE_RUNTIME_GUARD_HOOK=1
 ```
 
 ## Story System 运维

+ 12 - 4
docs/operations/plugin-release.md

@@ -19,15 +19,23 @@ python -X utf8 webnovel-writer/scripts/sync_plugin_version.py --version X.Y.Z --
 推荐使用 `Plugin Release` 工作流统一发版:
 
 1. 在本地执行版本同步(见上方命令)
-2. 提交并推送版本变更
-3. 打开仓库 Actions 页面,选择 `Plugin Release`
-4. 输入 `version`(如 `6.0.0`)和 `release_notes`
-5. 工作流自动执行:
+2. 运行插件包校验:
+
+```bash
+python -X utf8 webnovel-writer/scripts/validate_plugin_package.py
+```
+
+3. 提交并推送版本变更
+4. 打开仓库 Actions 页面,选择 `Plugin Release`
+5. 输入 `version`(如 `6.0.0`)和 `release_notes`
+6. 工作流自动执行:
    - 校验 `plugin.json`、`marketplace.json` 与 README 版本一致
    - 校验输入版本与仓库元数据一致
    - 创建并推送 `vX.Y.Z` Tag
    - 创建 GitHub Release
 
+`validate_plugin_package.py` 会复用 `sync_plugin_version.py` 的 README 当前版本解析规则,避免本地校验和现有 `Plugin Version Check` 工作流产生两套互相冲突的版本规则。
+
 ## 自动版本校验
 
 `Plugin Version Check` 工作流会在每次 Push / PR 时自动检查版本一致性。

+ 32 - 1
webnovel-writer/dashboard/app.py

@@ -225,6 +225,30 @@ def _build_env_status(project_root: Path) -> dict:
     }
 
 
+def _projection_status_for_commit(project_root: Path, chapter: int, commit_payload: dict) -> tuple[dict, str, dict]:
+    try:
+        from data_modules.projection_log import latest_projection_run, projection_status_from_run
+
+        latest_run = latest_projection_run(project_root, chapter=chapter)
+    except Exception:
+        latest_run = None
+
+    if isinstance(latest_run, dict):
+        projection_status = projection_status_from_run(latest_run)
+        return (
+            projection_status if isinstance(projection_status, dict) else {},
+            "projection_log",
+            {
+                "run_id": str(latest_run.get("run_id") or ""),
+                "status": str(latest_run.get("status") or ""),
+                "created_at": str(latest_run.get("created_at") or ""),
+                "commit_hash": str(latest_run.get("commit_hash") or ""),
+            },
+        )
+
+    return commit_payload.get("projection_status") or {}, "commit", {}
+
+
 # ---------------------------------------------------------------------------
 # 应用工厂
 # ---------------------------------------------------------------------------
@@ -503,11 +527,18 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
             meta = payload.get("meta") if isinstance(payload, dict) else {}
             provenance = payload.get("provenance") if isinstance(payload, dict) else {}
             chapter = int((meta or {}).get("chapter") or _extract_story_chapter(path))
+            projection_status, projection_source, projection_run = _projection_status_for_commit(
+                _get_project_root(),
+                chapter,
+                payload if isinstance(payload, dict) else {},
+            )
             items.append(
                 {
                     "chapter": chapter,
                     "status": str((meta or {}).get("status") or "missing"),
-                    "projection_status": payload.get("projection_status") or {},
+                    "projection_status": projection_status,
+                    "projection_source": projection_source,
+                    "projection_run": projection_run,
                     "write_fact_role": str((provenance or {}).get("write_fact_role") or ""),
                     "contract_refs": payload.get("contract_refs") or {},
                     "path": path.name,

+ 130 - 0
webnovel-writer/evals/fixtures/behavior/fast.json

@@ -0,0 +1,130 @@
+{
+  "schema_version": "webnovel-behavior-evals/v1",
+  "suite": "fast",
+  "cases": [
+    {
+      "id": "skill_frontmatter",
+      "type": "skill_frontmatter",
+      "description": "Every skill has routeable frontmatter."
+    },
+    {
+      "id": "skill_init_contract",
+      "type": "skill_contract",
+      "skill": "webnovel-init",
+      "description": "/webnovel-init keeps the deep collection gate and avoids writing canon before confirmation.",
+      "required": [
+        "未过充分性闸门,不执行 `webnovel.py init`",
+        "用户确认前不得写入",
+        "python -X utf8"
+      ]
+    },
+    {
+      "id": "skill_plan_contract",
+      "type": "skill_contract",
+      "skill": "webnovel-plan",
+      "description": "/webnovel-plan preserves outline blockers and Story System runtime contract refresh.",
+      "required": [
+        "若发现总纲与设定冲突,先阻断",
+        "placeholder-scan",
+        "story-system"
+      ]
+    },
+    {
+      "id": "skill_write_contract",
+      "type": "skill_contract",
+      "skill": "webnovel-write",
+      "description": "/webnovel-write calls runtime gates at natural boundaries.",
+      "required": [
+        "write-gate --chapter {chapter_num} --stage prewrite",
+        "write-gate --chapter {chapter_num} --stage precommit",
+        "write-gate --chapter {chapter_num} --stage postcommit",
+        "projections retry --chapter {chapter_num}"
+      ],
+      "ordered": [
+        [
+          "write-gate --chapter {chapter_num} --stage precommit",
+          "chapter-commit"
+        ],
+        [
+          "chapter-commit",
+          "write-gate --chapter {chapter_num} --stage postcommit"
+        ]
+      ]
+    },
+    {
+      "id": "skill_review_contract",
+      "type": "skill_contract",
+      "skill": "webnovel-review",
+      "description": "/webnovel-review relies on reviewer output and preserves blocking issue handling.",
+      "required": [
+        "必须通过 `Agent` 工具调用 `reviewer`",
+        "review-pipeline",
+        "blocking=true"
+      ]
+    },
+    {
+      "id": "skill_query_contract",
+      "type": "skill_contract",
+      "skill": "webnovel-query",
+      "description": "/webnovel-query stays read-only and prioritizes Story System sources.",
+      "required": [
+        "只读操作,不修改任何项目文件",
+        "memory-contract load-context",
+        "写前真源"
+      ]
+    },
+    {
+      "id": "skill_learn_contract",
+      "type": "skill_contract",
+      "skill": "webnovel-learn",
+      "description": "/webnovel-learn writes through the project-memory CLI instead of manual JSON edits.",
+      "required": [
+        "project-memory add-pattern",
+        "不删除旧记录,仅追加",
+        "禁止使用 `Write`"
+      ]
+    },
+    {
+      "id": "skill_dashboard_contract",
+      "type": "skill_contract",
+      "skill": "webnovel-dashboard",
+      "description": "/webnovel-dashboard remains read-only and exposes runtime health.",
+      "required": [
+        "只读",
+        "/api/story-runtime/health",
+        "python -m dashboard.server"
+      ]
+    },
+    {
+      "id": "skill_doctor_contract",
+      "type": "skill_contract",
+      "skill": "webnovel-doctor",
+      "description": "/webnovel-doctor stays read-only and uses project-status before detailed checks.",
+      "required": [
+        "只读诊断",
+        "project-status",
+        "doctor --format text"
+      ]
+    },
+    {
+      "id": "write_blocks_before_commit",
+      "type": "write_blocking_gate",
+      "description": "/webnovel-write keeps blocking review issues before commit and calls runtime gates."
+    },
+    {
+      "id": "data_agent_artifact_only",
+      "type": "data_agent_boundary",
+      "description": "data-agent produces commit artifacts and does not directly write projection read-models."
+    },
+    {
+      "id": "commit_drives_projection",
+      "type": "commit_projection_runtime",
+      "description": "ChapterCommitService remains the projection driver."
+    },
+    {
+      "id": "dashboard_read_only",
+      "type": "dashboard_read_only",
+      "description": "Dashboard exposes GET-only API semantics."
+    }
+  ]
+}

+ 138 - 0
webnovel-writer/hooks/guard_runtime_write.py

@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import json
+import os
+import re
+import sys
+from pathlib import PurePosixPath, PureWindowsPath
+from typing import Any
+
+
+DISABLE_ENV = "WEBNOVEL_DISABLE_RUNTIME_GUARD_HOOK"
+PROTECTED_SUFFIXES = (
+    ".story-system/commits/",
+    ".webnovel/state.json",
+    ".webnovel/index.db",
+    ".webnovel/vectors.db",
+    ".webnovel/memory_scratchpad.json",
+    ".webnovel/projection_log.jsonl",
+)
+ALLOWED_RUNTIME_MARKERS = (
+    "webnovel.py",
+    "chapter-commit",
+    "write-gate",
+    "projections retry",
+    "projections replay",
+)
+
+
+def _truthy(value: str | None) -> bool:
+    return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _load_input() -> dict[str, Any]:
+    raw = sys.stdin.read()
+    if not raw.strip():
+        return {}
+    try:
+        payload = json.loads(raw)
+    except json.JSONDecodeError:
+        return {}
+    return payload if isinstance(payload, dict) else {}
+
+
+def _normalized_path(value: object) -> str:
+    raw = str(value or "").strip()
+    if not raw:
+        return ""
+    raw = raw.replace("\\", "/")
+    try:
+        if ":" in raw[:3]:
+            raw = PureWindowsPath(str(value)).as_posix()
+        else:
+            raw = PurePosixPath(raw).as_posix()
+    except Exception:
+        pass
+    return raw.lower()
+
+
+def _deny(message: str) -> int:
+    payload = {
+        "hookSpecificOutput": {"permissionDecision": "deny"},
+        "systemMessage": message,
+    }
+    print(json.dumps(payload, ensure_ascii=False), file=sys.stderr)
+    return 2
+
+
+def _tool_input(payload: dict[str, Any]) -> dict[str, Any]:
+    value = payload.get("tool_input") or payload.get("toolInput") or payload.get("input") or {}
+    return value if isinstance(value, dict) else {}
+
+
+def _tool_name(payload: dict[str, Any]) -> str:
+    return str(payload.get("tool_name") or payload.get("toolName") or payload.get("tool") or "").strip()
+
+
+def _file_path_from_tool_input(tool_input: dict[str, Any]) -> str:
+    for key in ("file_path", "path", "filename"):
+        value = tool_input.get(key)
+        if value:
+            return str(value)
+    return ""
+
+
+def _is_protected_path(path: str) -> bool:
+    normalized = _normalized_path(path)
+    if not normalized:
+        return False
+    return any(suffix in normalized for suffix in PROTECTED_SUFFIXES)
+
+
+def _command_is_runtime_safe(command: str) -> bool:
+    lowered = command.lower()
+    return all(marker in lowered for marker in ("webnovel.py",)) and any(
+        marker in lowered for marker in ("chapter-commit", "projections retry", "projections replay")
+    )
+
+
+def _looks_like_direct_projection_write(command: str) -> bool:
+    lowered = command.lower().replace("\\", "/")
+    if _command_is_runtime_safe(lowered):
+        return False
+    protected_hit = any(suffix in lowered for suffix in PROTECTED_SUFFIXES)
+    if protected_hit and re.search(r"\b(>|out-file|set-content|add-content|copy-item|move-item|python|python3)\b", lowered):
+        return True
+    if "chapter_commit.py" in lowered and "webnovel.py" not in lowered:
+        return True
+    return False
+
+
+def main() -> int:
+    if _truthy(os.environ.get(DISABLE_ENV)):
+        return 0
+
+    payload = _load_input()
+    tool_input = _tool_input(payload)
+    tool = _tool_name(payload)
+
+    command = str(tool_input.get("command") or "")
+    if tool.lower() == "bash" or command:
+        if _looks_like_direct_projection_write(command):
+            return _deny(
+                "webnovel-writer blocked a direct write or bypass command for Story System/read-model files. Use webnovel.py write-gate, chapter-commit, or projections retry/replay instead."
+            )
+        return 0
+
+    path = _file_path_from_tool_input(tool_input)
+    if _is_protected_path(path):
+        return _deny(
+            "webnovel-writer blocked a direct edit to Story System/read-model files. Use runtime commands so commit/projection invariants stay consistent."
+        )
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 39 - 0
webnovel-writer/hooks/hooks.json

@@ -0,0 +1,39 @@
+{
+  "description": "Lightweight webnovel-writer runtime guardrails: short project status on session start and best-effort blocking for direct writes to Story System/read-model files.",
+  "hooks": {
+    "SessionStart": [
+      {
+        "matcher": "*",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "python -X utf8 \"${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py\"",
+            "timeout": 5
+          }
+        ]
+      }
+    ],
+    "PreToolUse": [
+      {
+        "matcher": "Write|Edit|MultiEdit",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "python -X utf8 \"${CLAUDE_PLUGIN_ROOT}/hooks/guard_runtime_write.py\"",
+            "timeout": 5
+          }
+        ]
+      },
+      {
+        "matcher": "Bash",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "python -X utf8 \"${CLAUDE_PLUGIN_ROOT}/hooks/guard_runtime_write.py\"",
+            "timeout": 5
+          }
+        ]
+      }
+    ]
+  }
+}

+ 66 - 0
webnovel-writer/hooks/session_start.py

@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+
+MAX_LINES = 8
+MAX_CHARS = 1000
+DISABLE_ENV = "WEBNOVEL_DISABLE_SESSION_STATUS_HOOK"
+
+
+def _truthy(value: str | None) -> bool:
+    return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _clip(text: str) -> str:
+    lines = [line for line in text.splitlines() if line.strip()][:MAX_LINES]
+    clipped = "\n".join(lines).strip()
+    if len(clipped) > MAX_CHARS:
+        clipped = clipped[: MAX_CHARS - 3].rstrip() + "..."
+    return clipped
+
+
+def main() -> int:
+    if _truthy(os.environ.get(DISABLE_ENV)):
+        return 0
+
+    plugin_root = Path(os.environ.get("CLAUDE_PLUGIN_ROOT") or Path(__file__).resolve().parents[1])
+    workspace_root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
+    webnovel = plugin_root / "scripts" / "webnovel.py"
+    if not webnovel.is_file():
+        return 0
+
+    try:
+        proc = subprocess.run(
+            [
+                sys.executable,
+                "-X",
+                "utf8",
+                str(webnovel),
+                "--project-root",
+                str(workspace_root),
+                "project-status",
+                "--format",
+                "summary",
+            ],
+            capture_output=True,
+            text=True,
+            encoding="utf-8",
+            timeout=4,
+        )
+    except Exception:
+        return 0
+
+    output = _clip(proc.stdout or proc.stderr or "")
+    if output:
+        print(output)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 297 - 0
webnovel-writer/scripts/data_modules/artifact_validator.py

@@ -0,0 +1,297 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from pydantic import ValidationError
+
+from .chapter_commit_schema import (
+    DisambiguationResult,
+    ExtractionResult,
+    FulfillmentResult,
+    ReviewResult,
+)
+
+
+SCHEMA_VERSION = "webnovel-artifact-validator/v1"
+
+ERROR_SCHEMA = "schema_error"
+ERROR_MISSING = "missing_artifact"
+ERROR_BLOCKING_REVIEW = "blocking_review"
+ERROR_MISSED_OUTLINE_NODE = "missed_outline_node"
+ERROR_PENDING_DISAMBIGUATION = "pending_disambiguation"
+ERROR_PROJECTION_FAILURE = "projection_failure"
+
+ARTIFACT_SCHEMAS = {
+    "review_result": ReviewResult,
+    "fulfillment_result": FulfillmentResult,
+    "disambiguation_result": DisambiguationResult,
+    "extraction_result": ExtractionResult,
+}
+
+
+def _issue(
+    issue_type: str,
+    *,
+    message: str,
+    severity: str = "blocker",
+    path: str = "",
+    field: str = "",
+    impact: str = "",
+    repair: str = "",
+) -> dict[str, str]:
+    return {
+        "type": issue_type,
+        "severity": severity,
+        "message": message,
+        "path": path,
+        "field": field,
+        "impact": impact,
+        "repair": repair,
+    }
+
+
+def _empty_report(artifact: str, path: str = "") -> dict[str, Any]:
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "artifact": artifact,
+        "path": path,
+        "ok": True,
+        "errors": [],
+        "warnings": [],
+        "payload": None,
+    }
+
+
+def _read_json_artifact(path: str | Path) -> tuple[Any, dict[str, Any] | None]:
+    artifact_path = Path(path)
+    if not artifact_path.is_file():
+        return None, _issue(
+            ERROR_MISSING,
+            message=f"artifact missing: {artifact_path}",
+            path=str(artifact_path),
+            impact="提交前 artifact 不完整,无法可靠生成 chapter commit。",
+            repair="重新运行 reviewer/data-agent,或按 schema 补齐该 JSON 文件。",
+        )
+    try:
+        return json.loads(artifact_path.read_text(encoding="utf-8")), None
+    except json.JSONDecodeError as exc:
+        return None, _issue(
+            ERROR_SCHEMA,
+            message=f"invalid JSON: {exc}",
+            path=str(artifact_path),
+            impact="artifact 无法被 runtime 读取。",
+            repair="修复 JSON 格式,确保文件为 UTF-8。",
+        )
+    except OSError as exc:
+        return None, _issue(
+            ERROR_SCHEMA,
+            message=f"artifact read failed: {exc}",
+            path=str(artifact_path),
+            impact="artifact 无法被 runtime 读取。",
+            repair="检查文件权限和路径是否正确。",
+        )
+
+
+def _schema_error_message(exc: Exception) -> str:
+    if isinstance(exc, ValidationError):
+        return "; ".join(str(error.get("msg") or "") for error in exc.errors()) or str(exc)
+    return str(exc)
+
+
+def _policy_issues(artifact: str, payload: dict[str, Any], path: str) -> list[dict[str, str]]:
+    issues: list[dict[str, str]] = []
+    if artifact == "review_result":
+        blocking_count = int(payload.get("blocking_count") or 0)
+        if blocking_count > 0:
+            issues.append(
+                _issue(
+                    ERROR_BLOCKING_REVIEW,
+                    message=f"review_result has {blocking_count} blocking issue(s)",
+                    path=path,
+                    field="blocking_count",
+                    impact="存在阻断级审查问题时不应进入提交。",
+                    repair="先定点修复 blocking issue,或让用户明确裁决后再继续。",
+                )
+            )
+    elif artifact == "fulfillment_result":
+        missed = payload.get("missed_nodes") or []
+        if missed:
+            issues.append(
+                _issue(
+                    ERROR_MISSED_OUTLINE_NODE,
+                    message=f"fulfillment_result missed {len(missed)} planned node(s)",
+                    path=path,
+                    field="missed_nodes",
+                    impact="大纲必须节点未覆盖,提交会把偏离章节固化为事实。",
+                    repair="补写遗漏节点,或经用户裁决修改本章规划。",
+                )
+            )
+    elif artifact == "disambiguation_result":
+        pending = payload.get("pending") or []
+        if pending:
+            issues.append(
+                _issue(
+                    ERROR_PENDING_DISAMBIGUATION,
+                    message=f"disambiguation_result has {len(pending)} pending item(s)",
+                    path=path,
+                    field="pending",
+                    impact="未消歧实体会污染角色、关系和事件投影。",
+                    repair="人工确认 pending 项,或把低置信实体从 extraction 中移除。",
+                )
+            )
+    return issues
+
+
+def validate_artifact_payload(artifact: str, payload: Any, *, path: str = "") -> dict[str, Any]:
+    if artifact not in ARTIFACT_SCHEMAS:
+        raise ValueError(f"unknown artifact: {artifact}")
+
+    report = _empty_report(artifact, path)
+    schema = ARTIFACT_SCHEMAS[artifact]
+    try:
+        model = schema.model_validate(payload)
+    except Exception as exc:
+        report["errors"].append(
+            _issue(
+                ERROR_SCHEMA,
+                message=_schema_error_message(exc),
+                path=path,
+                impact="artifact 字段形状不符合 chapter commit 权威 schema。",
+                repair="按 chapter_commit_schema.py 的顶层字段要求修正,不要包 fulfillment/disambiguation/extraction 外层。",
+            )
+        )
+        report["ok"] = False
+        return report
+
+    normalized = model.model_dump()
+    report["payload"] = normalized
+    report["errors"].extend(_policy_issues(artifact, normalized, path))
+    report["ok"] = not any(item.get("severity") == "blocker" for item in report["errors"])
+    return report
+
+
+def validate_artifact_file(artifact: str, path: str | Path) -> dict[str, Any]:
+    report = _empty_report(artifact, str(path))
+    payload, error = _read_json_artifact(path)
+    if error:
+        report["errors"].append(error)
+        report["ok"] = False
+        return report
+    return validate_artifact_payload(artifact, payload, path=str(path))
+
+
+def validate_review_result(path: str | Path) -> dict[str, Any]:
+    return validate_artifact_file("review_result", path)
+
+
+def validate_fulfillment_result(path: str | Path) -> dict[str, Any]:
+    return validate_artifact_file("fulfillment_result", path)
+
+
+def validate_disambiguation_result(path: str | Path) -> dict[str, Any]:
+    return validate_artifact_file("disambiguation_result", path)
+
+
+def validate_extraction_result(path: str | Path) -> dict[str, Any]:
+    return validate_artifact_file("extraction_result", path)
+
+
+def merge_reports(reports: list[dict[str, Any]], *, artifact: str = "chapter_commit_inputs") -> dict[str, Any]:
+    errors: list[dict[str, Any]] = []
+    warnings: list[dict[str, Any]] = []
+    payloads: dict[str, Any] = {}
+    for report in reports:
+        errors.extend(report.get("errors") or [])
+        warnings.extend(report.get("warnings") or [])
+        if report.get("payload") is not None:
+            payloads[str(report.get("artifact"))] = report.get("payload")
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "artifact": artifact,
+        "ok": not any(item.get("severity") == "blocker" for item in errors),
+        "errors": errors,
+        "warnings": warnings,
+        "payloads": payloads,
+        "reports": reports,
+    }
+
+
+def validate_commit_artifact_files(
+    *,
+    review_result: str | Path,
+    fulfillment_result: str | Path,
+    disambiguation_result: str | Path,
+    extraction_result: str | Path,
+) -> dict[str, Any]:
+    return merge_reports(
+        [
+            validate_review_result(review_result),
+            validate_fulfillment_result(fulfillment_result),
+            validate_disambiguation_result(disambiguation_result),
+            validate_extraction_result(extraction_result),
+        ]
+    )
+
+
+def validate_chapter_commit(path: str | Path) -> dict[str, Any]:
+    commit_path = Path(path)
+    report = _empty_report("chapter_commit", str(commit_path))
+    payload, error = _read_json_artifact(commit_path)
+    if error:
+        report["errors"].append(error)
+        report["ok"] = False
+        return report
+    if not isinstance(payload, dict):
+        report["errors"].append(
+            _issue(
+                ERROR_SCHEMA,
+                message="chapter_commit must be a JSON object",
+                path=str(commit_path),
+                impact="commit 文件无法作为事实主链读取。",
+                repair="从备份恢复 commit,或重新执行 chapter-commit。",
+            )
+        )
+        report["ok"] = False
+        return report
+
+    nested_reports = []
+    for artifact in ARTIFACT_SCHEMAS:
+        if artifact not in payload:
+            report["errors"].append(
+                _issue(
+                    ERROR_SCHEMA,
+                    message=f"chapter_commit missing {artifact}",
+                    path=str(commit_path),
+                    field=artifact,
+                    impact="commit 文件缺少提交 artifact 快照。",
+                    repair="重新执行 chapter-commit 生成完整 commit。",
+                )
+            )
+            continue
+        nested_reports.append(validate_artifact_payload(artifact, payload.get(artifact), path=str(commit_path)))
+
+    projection_status = payload.get("projection_status") or {}
+    if isinstance(projection_status, dict):
+        for writer, status in projection_status.items():
+            if str(status).startswith("failed:"):
+                report["errors"].append(
+                    _issue(
+                        ERROR_PROJECTION_FAILURE,
+                        message=f"projection {writer} failed: {status}",
+                        path=str(commit_path),
+                        field=f"projection_status.{writer}",
+                        impact="提交事实已生成,但 read-model 投影不完整。",
+                        repair="修复失败原因后补跑 projection retry/replay。",
+                    )
+                )
+
+    merged = merge_reports(nested_reports, artifact="chapter_commit_nested")
+    report["errors"].extend(merged["errors"])
+    report["warnings"].extend(merged["warnings"])
+    report["payload"] = payload
+    report["ok"] = not any(item.get("severity") == "blocker" for item in report["errors"])
+    return report

+ 66 - 24
webnovel-writer/scripts/data_modules/chapter_commit_service.py

@@ -99,49 +99,91 @@ class ChapterCommitService:
         write_json(path, payload)
         return path
 
-    def apply_projections(self, payload: Dict[str, Any]) -> Dict[str, Any]:
-        status = str((payload.get("meta") or {}).get("status") or "")
-        if status not in {"accepted", "rejected"}:
-            return payload
-
-        if status == "accepted":
-            chapter = int((payload.get("meta") or {}).get("chapter") or 0)
-            event_store = EventLogStore(self.project_root)
-            payload["accepted_events"] = event_store.normalize_events(
-                chapter, payload.get("accepted_events", [])
-            )
-            event_store.write_events(chapter, payload["accepted_events"])
-
-            proposals = AmendProposalTrigger().check(chapter, payload.get("accepted_events", []))
-            if proposals:
-                manager = IndexManager(DataModulesConfig.from_project_root(self.project_root))
-                with manager._get_conn() as conn:
-                    ensure_override_ledger_columns(conn)
-                    persist_amend_proposals(conn, chapter, proposals)
-                    conn.commit()
-
+    def _projection_writers(self) -> dict[str, Any]:
         from .index_projection_writer import IndexProjectionWriter
         from .memory_projection_writer import MemoryProjectionWriter
         from .state_projection_writer import StateProjectionWriter
         from .summary_projection_writer import SummaryProjectionWriter
         from .vector_projection_writer import VectorProjectionWriter
 
-        writers = {
+        return {
             "state": StateProjectionWriter(self.project_root),
             "index": IndexProjectionWriter(self.project_root),
             "summary": SummaryProjectionWriter(self.project_root),
             "memory": MemoryProjectionWriter(self.project_root),
             "vector": VectorProjectionWriter(self.project_root),
         }
+
+    def _writer_status(self, result: dict[str, Any]) -> str:
+        if result.get("applied"):
+            return "done"
+        reason = str(result.get("reason") or "").strip()
+        if reason in {"not_required", "commit_rejected"}:
+            return "skipped"
+        if reason.startswith("error:"):
+            return f"failed:{reason[6:] or 'writer_error'}"
+        return "skipped"
+
+    def apply_projection_writers(self, payload: Dict[str, Any]) -> Dict[str, Any]:
+        status = str((payload.get("meta") or {}).get("status") or "")
+        if status not in {"accepted", "rejected"}:
+            return payload
+
+        payload.setdefault("projection_status", {})
+        if not isinstance(payload["projection_status"], dict):
+            payload["projection_status"] = {}
+
+        writers = self._projection_writers()
         required_writers = set(EventProjectionRouter().required_writers(payload))
+        writer_results: dict[str, dict[str, Any]] = {}
         for name, writer in writers.items():
             if name not in required_writers:
                 payload["projection_status"][name] = "skipped"
+                writer_results[name] = {"status": "skipped", "reason": "not_required"}
                 continue
             try:
                 result = writer.apply(payload)
-                payload["projection_status"][name] = "done" if result.get("applied") else "skipped"
+                payload["projection_status"][name] = self._writer_status(result)
+                writer_results[name] = {
+                    "status": payload["projection_status"][name],
+                    "result": result,
+                }
             except Exception as exc:
                 payload["projection_status"][name] = f"failed:{exc}"
-        self.persist_commit(payload)
+                writer_results[name] = {"status": "failed", "error": str(exc)}
+        commit_path = self.persist_commit(payload)
+        try:
+            from .projection_log import append_projection_run
+
+            append_projection_run(
+                self.project_root,
+                payload,
+                writer_results,
+                commit_path=commit_path,
+            )
+        except Exception:
+            pass
         return payload
+
+    def apply_projections(self, payload: Dict[str, Any]) -> Dict[str, Any]:
+        status = str((payload.get("meta") or {}).get("status") or "")
+        if status not in {"accepted", "rejected"}:
+            return payload
+
+        if status == "accepted":
+            chapter = int((payload.get("meta") or {}).get("chapter") or 0)
+            event_store = EventLogStore(self.project_root)
+            payload["accepted_events"] = event_store.normalize_events(
+                chapter, payload.get("accepted_events", [])
+            )
+            event_store.write_events(chapter, payload["accepted_events"])
+
+            proposals = AmendProposalTrigger().check(chapter, payload.get("accepted_events", []))
+            if proposals:
+                manager = IndexManager(DataModulesConfig.from_project_root(self.project_root))
+                with manager._get_conn() as conn:
+                    ensure_override_ledger_columns(conn)
+                    persist_amend_proposals(conn, chapter, proposals)
+                    conn.commit()
+
+        return self.apply_projection_writers(payload)

+ 576 - 0
webnovel-writer/scripts/data_modules/doctor.py

@@ -0,0 +1,576 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import json
+import platform
+import sqlite3
+import sys
+from pathlib import Path
+from typing import Any
+
+from .config import DataModulesConfig
+from .project_phase import (
+    INIT_REQUIRED_DIRS,
+    INIT_REQUIRED_FILES,
+    PHASE_INIT_READY,
+    PHASE_INIT_SCAFFOLDED,
+    PHASE_NO_PROJECT,
+    ProjectPhaseSnapshot,
+    contract_files_for_chapter,
+    resolve_project_phase,
+)
+from .projection_log import (
+    latest_projection_run,
+    projection_log_path,
+    projection_run_failed,
+    projection_run_pending,
+)
+from .story_runtime_health import build_story_runtime_health
+
+
+SCHEMA_VERSION = "webnovel-doctor/v1"
+CHECK_OK = "ok"
+CHECK_WARNING = "warning"
+CHECK_ERROR = "error"
+CHECK_SKIPPED = "skipped"
+
+
+def _check(
+    check_id: str,
+    *,
+    status: str,
+    severity: str,
+    message: str,
+    path: str = "",
+    expected: str = "",
+    actual: str = "",
+    impact: str = "",
+    repair: str = "",
+) -> dict[str, Any]:
+    return {
+        "id": check_id,
+        "status": status,
+        "severity": severity,
+        "message": message,
+        "path": path,
+        "expected": expected,
+        "actual": actual,
+        "impact": impact,
+        "repair": repair,
+    }
+
+
+def _rel(project_root: Path, path: Path) -> str:
+    try:
+        return str(path.relative_to(project_root))
+    except ValueError:
+        return str(path)
+
+
+def _read_json(path: Path) -> tuple[dict[str, Any], str]:
+    try:
+        payload = json.loads(path.read_text(encoding="utf-8"))
+    except FileNotFoundError:
+        return {}, "missing"
+    except json.JSONDecodeError as exc:
+        return {}, f"invalid json: {exc}"
+    except OSError as exc:
+        return {}, f"read error: {exc}"
+    if not isinstance(payload, dict):
+        return {}, "json root is not object"
+    return payload, ""
+
+
+def _expected_profile(snapshot: ProjectPhaseSnapshot) -> dict[str, Any]:
+    expected_files = list(INIT_REQUIRED_FILES)
+    expected_dirs = list(INIT_REQUIRED_DIRS)
+    if snapshot.phase not in {PHASE_NO_PROJECT, PHASE_INIT_SCAFFOLDED, PHASE_INIT_READY}:
+        expected_files.extend(snapshot.missing_contract_files)
+    if snapshot.target_chapter > 0 and snapshot.phase not in {PHASE_NO_PROJECT, PHASE_INIT_SCAFFOLDED, PHASE_INIT_READY}:
+        expected_files.extend(
+            str(path.relative_to(Path(snapshot.project_root)))
+            for path in contract_files_for_chapter(Path(snapshot.project_root), snapshot.target_chapter).values()
+        )
+    return {
+        "phase": snapshot.phase,
+        "target_chapter": snapshot.target_chapter,
+        "files": sorted(set(expected_files)),
+        "dirs": sorted(set(expected_dirs)),
+    }
+
+
+def _preflight_checks(preflight_report: dict[str, Any] | None) -> list[dict[str, Any]]:
+    if not preflight_report:
+        return []
+    checks: list[dict[str, Any]] = []
+    for item in preflight_report.get("checks") or []:
+        if not isinstance(item, dict):
+            continue
+        ok = bool(item.get("ok"))
+        name = str(item.get("name") or "unknown")
+        checks.append(
+            _check(
+                f"preflight.{name}",
+                status=CHECK_OK if ok else CHECK_ERROR,
+                severity="info" if ok else "blocker",
+                message=f"preflight {name} {'ok' if ok else 'failed'}",
+                path=str(item.get("path") or ""),
+                actual=str(item.get("error") or ""),
+                impact="" if ok else "统一 CLI 或项目解析可能不可用。",
+                repair="" if ok else "先修复 preflight 输出的路径或 project_root 问题。",
+            )
+        )
+    return checks
+
+
+def _file_checks(project_root: Path, snapshot: ProjectPhaseSnapshot) -> list[dict[str, Any]]:
+    checks: list[dict[str, Any]] = []
+    for rel in INIT_REQUIRED_DIRS:
+        path = project_root / rel
+        exists = path.is_dir()
+        checks.append(
+            _check(
+                f"file.dir.{rel}",
+                status=CHECK_OK if exists else CHECK_ERROR,
+                severity="info" if exists else "blocker",
+                message=f"required directory {rel}",
+                path=str(path),
+                expected="directory exists",
+                actual="exists" if exists else "missing",
+                impact="" if exists else "项目骨架不完整,后续写作/备份/报告可能写入失败。",
+                repair="" if exists else "重新运行 /webnovel-init,或手动创建该目录后再运行 doctor。",
+            )
+        )
+    for rel in INIT_REQUIRED_FILES:
+        path = project_root / rel
+        exists = path.is_file()
+        checks.append(
+            _check(
+                f"file.required.{rel}",
+                status=CHECK_OK if exists else CHECK_ERROR,
+                severity="info" if exists else "blocker",
+                message=f"required file {rel}",
+                path=str(path),
+                expected="file exists",
+                actual="exists" if exists else "missing",
+                impact="" if exists else "项目初始化产物缺失,当前阶段判断和后续流程会不可靠。",
+                repair="" if exists else "使用 /webnovel-init 补齐项目骨架,或按 init_project.py 模板补齐文件。",
+            )
+        )
+
+    if snapshot.phase not in {PHASE_NO_PROJECT, PHASE_INIT_SCAFFOLDED, PHASE_INIT_READY} and snapshot.target_chapter > 0:
+        for name, path in contract_files_for_chapter(project_root, snapshot.target_chapter).items():
+            exists = path.is_file()
+            checks.append(
+                _check(
+                    f"file.contract.{name}",
+                    status=CHECK_OK if exists else CHECK_ERROR,
+                    severity="info" if exists else "blocker",
+                    message=f"story contract {name}",
+                    path=str(path),
+                    expected="file exists",
+                    actual="exists" if exists else "missing",
+                    impact="" if exists else "写章上下文缺少主链合同,容易用旧 state 或旧大纲写偏。",
+                    repair="" if exists else "运行 webnovel.py story-system ... --persist --emit-runtime-contracts --chapter N。",
+                )
+            )
+    return checks
+
+
+def _json_checks(project_root: Path) -> list[dict[str, Any]]:
+    checks: list[dict[str, Any]] = []
+    json_files = [
+        project_root / ".webnovel" / "state.json",
+        project_root / ".story-system" / "MASTER_SETTING.json",
+    ]
+    for path in json_files:
+        if not path.exists():
+            checks.append(
+                _check(
+                    f"json.{_rel(project_root, path)}",
+                    status=CHECK_SKIPPED,
+                    severity="info",
+                    message=f"{_rel(project_root, path)} not present",
+                    path=str(path),
+                    expected="valid JSON object when present",
+                    actual="missing",
+                )
+            )
+            continue
+        payload, error = _read_json(path)
+        checks.append(
+            _check(
+                f"json.{_rel(project_root, path)}",
+                status=CHECK_OK if not error else CHECK_ERROR,
+                severity="info" if not error else "blocker",
+                message=f"{_rel(project_root, path)} json parse",
+                path=str(path),
+                expected="valid JSON object",
+                actual="ok" if not error else error,
+                impact="" if not error else "JSON 无法读取会导致 CLI、dashboard 或状态推导失败。",
+                repair="" if not error else "用 UTF-8 修复 JSON 格式;必要时从 git/backup 恢复。",
+            )
+        )
+        if path.name == "state.json" and not error:
+            for key in ("project_info", "progress"):
+                checks.append(
+                    _check(
+                        f"json.state.{key}",
+                        status=CHECK_OK if isinstance(payload.get(key), dict) else CHECK_WARNING,
+                        severity="info" if isinstance(payload.get(key), dict) else "warning",
+                        message=f"state.json contains {key}",
+                        path=str(path),
+                        expected="object field",
+                        actual=type(payload.get(key)).__name__,
+                        impact="" if isinstance(payload.get(key), dict) else "旧项目或手改 state 可能缺少运行时字段。",
+                        repair="" if isinstance(payload.get(key), dict) else "运行 webnovel.py init 到同一目录可增量补齐 schema。",
+                    )
+                )
+    return checks
+
+
+def _sqlite_table_count(path: Path, table: str) -> tuple[bool, int, str]:
+    if not path.is_file():
+        return False, 0, "missing"
+    try:
+        with sqlite3.connect(str(path)) as conn:
+            row = conn.execute(
+                "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
+                (table,),
+            ).fetchone()
+            if not row:
+                return False, 0, "table_missing"
+            count_row = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
+            return True, int(count_row[0] or 0) if count_row else 0, ""
+    except sqlite3.Error as exc:
+        return False, 0, str(exc)
+
+
+def _sqlite_checks(project_root: Path) -> list[dict[str, Any]]:
+    checks: list[dict[str, Any]] = []
+    cfg = DataModulesConfig.from_project_root(project_root)
+    for db_path, table, check_id, impact in (
+        (cfg.index_db, "chapters", "sqlite.index_db.chapters", "查询、关系图谱和 dashboard 章节统计会降级。"),
+        (cfg.vector_db, "vectors", "sqlite.vector_db.vectors", "RAG 向量召回不可用,会退化为关键词或空召回。"),
+    ):
+        exists = db_path.is_file()
+        table_ok, count, error = _sqlite_table_count(db_path, table)
+        if not exists:
+            checks.append(
+                _check(
+                    check_id,
+                    status=CHECK_WARNING,
+                    severity="warning",
+                    message=f"{db_path.name} missing",
+                    path=str(db_path),
+                    expected=f"sqlite db with {table} table",
+                    actual="missing",
+                    impact=impact,
+                    repair="运行对应 index/rag 命令生成数据库;init 刚结束时可暂时忽略。",
+                )
+            )
+            continue
+        checks.append(
+            _check(
+                check_id,
+                status=CHECK_OK if table_ok else CHECK_WARNING,
+                severity="info" if table_ok else "warning",
+                message=f"{db_path.name}.{table}",
+                path=str(db_path),
+                expected=f"{table} table readable",
+                actual=f"rows={count}" if table_ok else error,
+                impact="" if table_ok else impact,
+                repair="" if table_ok else "重新运行索引/RAG 构建命令;若 sqlite 损坏,从备份恢复。",
+            )
+        )
+    return checks
+
+
+def _rag_checks(project_root: Path) -> list[dict[str, Any]]:
+    cfg = DataModulesConfig.from_project_root(project_root)
+    checks: list[dict[str, Any]] = []
+    for key, present, base_url, model in (
+        ("embed", bool(str(cfg.embed_api_key or "").strip()), cfg.embed_base_url, cfg.embed_model),
+        ("rerank", bool(str(cfg.rerank_api_key or "").strip()), cfg.rerank_base_url, cfg.rerank_model),
+    ):
+        checks.append(
+            _check(
+                f"rag.{key}.api_key",
+                status=CHECK_OK if present else CHECK_WARNING,
+                severity="info" if present else "warning",
+                message=f"{key} api key configured",
+                expected="api key present in env or .env",
+                actual=f"present; model={model}; base_url={base_url}" if present else f"missing; model={model}; base_url={base_url}",
+                impact="" if present else "RAG 相关调用会不可用或降级。",
+                repair="" if present else "复制 .env.example 为 .env,并填写对应 API key;不要把真实 key 提交到仓库。",
+            )
+        )
+    return checks
+
+
+def _projection_log_checks(project_root: Path, snapshot: ProjectPhaseSnapshot) -> list[dict[str, Any]]:
+    log_path = projection_log_path(project_root)
+    latest_commit = snapshot.latest_commit
+    if latest_commit is None:
+        return [
+            _check(
+                "projection_log.present",
+                status=CHECK_SKIPPED,
+                severity="info",
+                message="no commit yet; projection log not required",
+                path=str(log_path),
+                expected="projection log after first commit",
+                actual="no commit",
+            )
+        ]
+    if not log_path.is_file():
+        return [
+            _check(
+                "projection_log.present",
+                status=CHECK_WARNING,
+                severity="warning",
+                message="projection log missing for project with commits",
+                path=str(log_path),
+                expected="projection_log.jsonl exists after projection run",
+                actual="missing",
+                impact="无法从独立执行日志定位历史 projection run;仍可兼容读取 commit 内 projection_status。",
+                repair="后续 chapter-commit 会自动双写 projection_log;旧项目可暂时忽略。",
+            )
+        ]
+    latest = latest_projection_run(project_root, chapter=latest_commit.chapter)
+    if not latest:
+        return [
+            _check(
+                "projection_log.latest_run",
+                status=CHECK_WARNING,
+                severity="warning",
+                message="projection log has no run for latest commit",
+                path=str(log_path),
+                expected=f"run for chapter {latest_commit.chapter}",
+                actual="missing",
+                impact="最新 commit 的投影执行历史不可见。",
+                repair="后续 projection retry/replay 可补齐;当前仍兼容 commit 内 projection_status。",
+            )
+        ]
+    failed = projection_run_failed(latest)
+    pending = projection_run_pending(latest)
+    status_ok = not failed and not pending
+    return [
+        _check(
+            "projection_log.latest_run",
+            status=CHECK_OK if status_ok else CHECK_ERROR,
+            severity="info" if status_ok else "blocker",
+            message="latest projection log run",
+            path=str(log_path),
+            expected="latest run status done/skipped",
+            actual=f"chapter={latest.get('chapter')} status={latest.get('status')}",
+            impact="read-model 投影失败或未完成,需要补跑。" if not status_ok else "",
+            repair="查看 projection_log.jsonl 的 writers 字段,修复后补跑 projection retry/replay。" if not status_ok else "",
+        )
+    ]
+
+
+def _python_checks() -> list[dict[str, Any]]:
+    checks = [
+        _check(
+            "python.version",
+            status=CHECK_OK if sys.version_info >= (3, 10) else CHECK_ERROR,
+            severity="info" if sys.version_info >= (3, 10) else "blocker",
+            message="python version",
+            expected=">= 3.10",
+            actual=platform.python_version(),
+            impact="" if sys.version_info >= (3, 10) else "运行时依赖 Python 3.10+ 语法和库行为。",
+            repair="" if sys.version_info >= (3, 10) else "切换到 Python 3.10 或更高版本。",
+        )
+    ]
+    for module_name in ("aiohttp", "filelock", "pydantic"):
+        found = importlib.util.find_spec(module_name) is not None
+        checks.append(
+            _check(
+                f"python.import.{module_name}",
+                status=CHECK_OK if found else CHECK_ERROR,
+                severity="info" if found else "blocker",
+                message=f"import {module_name}",
+                expected="module importable",
+                actual="present" if found else "missing",
+                impact="" if found else "核心数据模块可能无法运行。",
+                repair="" if found else "运行 python -m pip install -r scripts/requirements.txt。",
+            )
+        )
+    for module_name in ("fastapi", "uvicorn", "watchdog"):
+        found = importlib.util.find_spec(module_name) is not None
+        checks.append(
+            _check(
+                f"python.import.dashboard.{module_name}",
+                status=CHECK_OK if found else CHECK_WARNING,
+                severity="info" if found else "warning",
+                message=f"import {module_name}",
+                expected="module importable for dashboard",
+                actual="present" if found else "missing",
+                impact="" if found else "Dashboard 服务端可能无法启动。",
+                repair="" if found else "运行 python -m pip install -r dashboard/requirements.txt。",
+            )
+        )
+    return checks
+
+
+def _dashboard_checks(plugin_root: Path | None = None) -> list[dict[str, Any]]:
+    if plugin_root is None:
+        plugin_root = Path(__file__).resolve().parents[2]
+    dashboard_root = plugin_root / "dashboard"
+    dist = dashboard_root / "frontend" / "dist"
+    package_json = dashboard_root / "frontend" / "package.json"
+    requirements = dashboard_root / "requirements.txt"
+    checks: list[dict[str, Any]] = []
+    for check_id, path, expected in (
+        ("dashboard.root", dashboard_root, "directory exists"),
+        ("dashboard.frontend.dist", dist, "built frontend dist exists"),
+        ("dashboard.frontend.package_json", package_json, "package.json exists"),
+        ("dashboard.requirements", requirements, "requirements.txt exists"),
+    ):
+        exists = path.is_dir() if expected.startswith("directory") or path == dist else path.is_file()
+        checks.append(
+            _check(
+                check_id,
+                status=CHECK_OK if exists else CHECK_WARNING,
+                severity="info" if exists else "warning",
+                message=check_id,
+                path=str(path),
+                expected=expected,
+                actual="exists" if exists else "missing",
+                impact="" if exists else "Dashboard 可能无法打开或发布包缺少前端产物。",
+                repair="" if exists else "按 dashboard 文档安装/构建前端,或检查发布包是否遗漏 dist。",
+            )
+        )
+    return checks
+
+
+def build_doctor_report(
+    project_root: str | Path | None,
+    *,
+    chapter: int | None = None,
+    deep: bool = False,
+    preflight_report: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    snapshot = resolve_project_phase(project_root, chapter=chapter)
+    checks: list[dict[str, Any]] = []
+    checks.extend(_preflight_checks(preflight_report))
+
+    if snapshot.phase == PHASE_NO_PROJECT or not snapshot.project_root:
+        checks.append(
+            _check(
+                "project.root",
+                status=CHECK_ERROR,
+                severity="blocker",
+                message="project root not resolved",
+                path=str(project_root or ""),
+                expected=".webnovel/state.json",
+                actual="missing",
+                impact="无法判断项目状态,也不能安全运行写作链路。",
+                repair="先运行 /webnovel-init,或运行 webnovel.py use <project_root> 绑定已有项目。",
+            )
+        )
+    else:
+        root = Path(snapshot.project_root)
+        checks.extend(_file_checks(root, snapshot))
+        checks.extend(_json_checks(root))
+        try:
+            runtime_health = build_story_runtime_health(root, chapter=chapter)
+        except Exception as exc:
+            runtime_health = {"error": str(exc)}
+            checks.append(
+                _check(
+                    "story_runtime.health",
+                    status=CHECK_WARNING,
+                    severity="warning",
+                    message="story runtime health failed",
+                    actual=str(exc),
+                    impact="Story System 主链健康摘要不可用。",
+                    repair="检查 .story-system 合同与 commit JSON 是否可读。",
+                )
+            )
+        else:
+            checks.append(
+                _check(
+                    "story_runtime.health",
+                    status=CHECK_OK if runtime_health.get("mainline_ready") else CHECK_WARNING,
+                    severity="info" if runtime_health.get("mainline_ready") else "warning",
+                    message="story runtime health",
+                    expected="mainline_ready true when writing stage",
+                    actual=json.dumps(runtime_health, ensure_ascii=False),
+                    impact="" if runtime_health.get("mainline_ready") else "当前章节可能会使用 fallback source。",
+                    repair="" if runtime_health.get("mainline_ready") else "补齐 Story System 合同和 accepted commit 后再写。",
+                )
+            )
+        checks.extend(_sqlite_checks(root))
+        checks.extend(_projection_log_checks(root, snapshot))
+        checks.extend(_rag_checks(root))
+
+    checks.extend(_python_checks())
+    if deep:
+        checks.extend(_dashboard_checks())
+
+    blocking = [item for item in checks if item["severity"] == "blocker" and item["status"] == CHECK_ERROR]
+    warnings = [item for item in checks if item["status"] == CHECK_WARNING]
+    recommended_actions = [item["repair"] for item in checks if item.get("repair")]
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "ok": not blocking,
+        "project_root": snapshot.project_root,
+        "mode": "deep" if deep else "standard",
+        "phase": snapshot.phase,
+        "expected_profile": _expected_profile(snapshot),
+        "blocking_count": len(blocking),
+        "warning_count": len(warnings),
+        "checks": checks,
+        "recommended_actions": list(dict.fromkeys(recommended_actions)),
+    }
+
+
+def format_doctor_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} webnovel-doctor",
+        f"project_root: {report.get('project_root') or '(未解析)'}",
+        f"phase: {report.get('phase')}",
+        f"blocking: {report.get('blocking_count')} warnings: {report.get('warning_count')}",
+    ]
+    for item in report.get("checks") or []:
+        if item.get("status") == CHECK_OK:
+            continue
+        lines.append(f"{str(item.get('status')).upper()} {item.get('id')}: {item.get('message')}")
+        if item.get("path"):
+            lines.append(f"  path: {item.get('path')}")
+        if item.get("actual"):
+            lines.append(f"  actual: {item.get('actual')}")
+        if item.get("impact"):
+            lines.append(f"  impact: {item.get('impact')}")
+        if item.get("repair"):
+            lines.append(f"  repair: {item.get('repair')}")
+    actions = report.get("recommended_actions") or []
+    if actions:
+        lines.append("recommended_actions:")
+        lines.extend(f"- {action}" for action in actions[:8])
+    return "\n".join(lines)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Run read-only webnovel project doctor")
+    parser.add_argument("--project-root", default="", help="书项目根目录")
+    parser.add_argument("--chapter", type=int, default=None, help="目标章节号")
+    parser.add_argument("--deep", action="store_true", help="包含 dashboard 等较深检查")
+    parser.add_argument("--format", choices=["text", "json"], default="text")
+    args = parser.parse_args()
+
+    report = build_doctor_report(args.project_root or None, chapter=args.chapter, deep=args.deep)
+    print(format_doctor_report(report, args.format))
+    raise SystemExit(0 if report.get("ok") else 1)
+
+
+if __name__ == "__main__":
+    main()

+ 397 - 0
webnovel-writer/scripts/data_modules/project_phase.py

@@ -0,0 +1,397 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+try:
+    from chapter_outline_loader import volume_num_for_chapter_from_state
+    from chapter_paths import find_chapter_file, volume_num_for_chapter
+except ImportError:  # pragma: no cover
+    from scripts.chapter_outline_loader import volume_num_for_chapter_from_state
+    from scripts.chapter_paths import find_chapter_file, volume_num_for_chapter
+
+from .projection_log import latest_projection_run, projection_status_from_run
+
+
+PHASE_NO_PROJECT = "no_project"
+PHASE_UNKNOWN = "unknown"
+PHASE_INIT_SCAFFOLDED = "init_scaffolded"
+PHASE_INIT_READY = "init_ready"
+PHASE_PLAN_IN_PROGRESS = "plan_in_progress"
+PHASE_CHAPTER_CONTRACT_READY = "chapter_contract_ready"
+PHASE_DRAFT_IN_PROGRESS = "draft_in_progress"
+PHASE_READY_TO_COMMIT = "ready_to_commit"
+PHASE_CHAPTER_COMMITTED = "chapter_committed"
+PHASE_PROJECTION_FAILED = "projection_failed"
+
+PHASES = (
+    PHASE_NO_PROJECT,
+    PHASE_UNKNOWN,
+    PHASE_INIT_SCAFFOLDED,
+    PHASE_INIT_READY,
+    PHASE_PLAN_IN_PROGRESS,
+    PHASE_CHAPTER_CONTRACT_READY,
+    PHASE_DRAFT_IN_PROGRESS,
+    PHASE_READY_TO_COMMIT,
+    PHASE_CHAPTER_COMMITTED,
+    PHASE_PROJECTION_FAILED,
+)
+
+INIT_REQUIRED_DIRS = (
+    ".webnovel",
+    ".webnovel/backups",
+    ".webnovel/archive",
+    ".webnovel/summaries",
+    "设定集",
+    "大纲",
+    "正文",
+    "审查报告",
+)
+
+INIT_REQUIRED_FILES = (
+    ".webnovel/state.json",
+    "设定集/世界观.md",
+    "设定集/力量体系.md",
+    "设定集/主角卡.md",
+    "设定集/反派设计.md",
+    "大纲/总纲.md",
+    ".env.example",
+)
+
+COMMIT_ARTIFACT_FILES = (
+    ".webnovel/tmp/review_results.json",
+    ".webnovel/tmp/fulfillment_result.json",
+    ".webnovel/tmp/disambiguation_result.json",
+    ".webnovel/tmp/extraction_result.json",
+)
+
+_CHAPTER_FILE_RE = re.compile(r"chapter_(\d{3,4})")
+
+
+@dataclass(frozen=True)
+class ChapterCommitInfo:
+    chapter: int
+    status: str
+    path: str
+    projection_status: dict[str, str] = field(default_factory=dict)
+    projection_source: str = "commit"
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "chapter": self.chapter,
+            "status": self.status,
+            "path": self.path,
+            "projection_status": dict(self.projection_status),
+            "projection_source": self.projection_source,
+        }
+
+
+@dataclass(frozen=True)
+class ProjectPhaseSnapshot:
+    project_root: str
+    phase: str
+    target_chapter: int
+    latest_accepted_chapter: int
+    latest_commit: ChapterCommitInfo | None = None
+    state_current_chapter: int = 0
+    missing_init_files: tuple[str, ...] = ()
+    missing_init_dirs: tuple[str, ...] = ()
+    missing_contract_files: tuple[str, ...] = ()
+    missing_commit_artifacts: tuple[str, ...] = ()
+    draft_file: str = ""
+    blocking: tuple[str, ...] = ()
+    warnings: tuple[str, ...] = ()
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "project_root": self.project_root,
+            "phase": self.phase,
+            "target_chapter": self.target_chapter,
+            "latest_accepted_chapter": self.latest_accepted_chapter,
+            "latest_commit": self.latest_commit.to_dict() if self.latest_commit else None,
+            "state_current_chapter": self.state_current_chapter,
+            "missing_init_files": list(self.missing_init_files),
+            "missing_init_dirs": list(self.missing_init_dirs),
+            "missing_contract_files": list(self.missing_contract_files),
+            "missing_commit_artifacts": list(self.missing_commit_artifacts),
+            "draft_file": self.draft_file,
+            "blocking": list(self.blocking),
+            "warnings": list(self.warnings),
+        }
+
+
+def _read_json_object(path: Path) -> tuple[dict[str, Any], str]:
+    try:
+        payload = json.loads(path.read_text(encoding="utf-8"))
+    except FileNotFoundError:
+        return {}, "missing"
+    except json.JSONDecodeError as exc:
+        return {}, f"invalid_json:{exc}"
+    except OSError as exc:
+        return {}, f"read_error:{exc}"
+    if not isinstance(payload, dict):
+        return {}, "not_object"
+    return payload, ""
+
+
+def _chapter_from_path(path: Path) -> int:
+    match = _CHAPTER_FILE_RE.search(path.name)
+    if not match:
+        return 0
+    try:
+        return int(match.group(1))
+    except ValueError:
+        return 0
+
+
+def _state_current_chapter(project_root: Path) -> tuple[int, str]:
+    state_path = project_root / ".webnovel" / "state.json"
+    state, error = _read_json_object(state_path)
+    if error:
+        return 0, error
+    progress = state.get("progress") if isinstance(state, dict) else {}
+    if not isinstance(progress, dict):
+        return 0, ""
+    try:
+        return max(0, int(progress.get("current_chapter") or 0)), ""
+    except (TypeError, ValueError):
+        return 0, ""
+
+
+def _scan_commits(project_root: Path) -> list[ChapterCommitInfo]:
+    commits_dir = project_root / ".story-system" / "commits"
+    if not commits_dir.is_dir():
+        return []
+
+    commits: list[ChapterCommitInfo] = []
+    for path in sorted(commits_dir.glob("chapter_*.commit.json")):
+        chapter = _chapter_from_path(path)
+        if chapter <= 0:
+            continue
+        payload, error = _read_json_object(path)
+        meta = payload.get("meta") if isinstance(payload, dict) else {}
+        if error:
+            status = "invalid"
+        elif isinstance(meta, dict):
+            status = str(meta.get("status") or "missing").strip() or "missing"
+        else:
+            status = "missing"
+        raw_projection = payload.get("projection_status") if isinstance(payload, dict) else {}
+        projection_status = {
+            str(key): str(value)
+            for key, value in (raw_projection or {}).items()
+            if isinstance(raw_projection, dict)
+        }
+        projection_source = "commit"
+        try:
+            latest_run = latest_projection_run(project_root, chapter=chapter)
+            logged_projection_status = projection_status_from_run(latest_run)
+        except Exception:
+            logged_projection_status = {}
+        if logged_projection_status:
+            projection_status = logged_projection_status
+            projection_source = "projection_log"
+        commits.append(
+            ChapterCommitInfo(
+                chapter=chapter,
+                status=status,
+                path=str(path),
+                projection_status=projection_status,
+                projection_source=projection_source,
+            )
+        )
+    return commits
+
+
+def _latest_story_system_chapter(project_root: Path) -> int:
+    story_root = project_root / ".story-system"
+    if not story_root.is_dir():
+        return 0
+    chapters: list[int] = []
+    for pattern in (
+        "chapters/chapter_*.json",
+        "reviews/chapter_*.review.json",
+        "commits/chapter_*.commit.json",
+    ):
+        chapters.extend(_chapter_from_path(path) for path in story_root.glob(pattern))
+    return max(chapters or [0])
+
+
+def _latest_draft_chapter(project_root: Path) -> int:
+    chapters_dir = project_root / "正文"
+    if not chapters_dir.is_dir():
+        return 0
+    chapters: list[int] = []
+    for path in chapters_dir.rglob("第*章*.md"):
+        match = re.search(r"第0*(\d+)章", path.name)
+        if not match:
+            continue
+        try:
+            chapters.append(int(match.group(1)))
+        except ValueError:
+            continue
+    return max(chapters or [0])
+
+
+def _target_chapter(
+    project_root: Path,
+    chapter: int | None,
+    *,
+    latest_accepted_chapter: int,
+) -> int:
+    if chapter is not None:
+        try:
+            return max(0, int(chapter))
+        except (TypeError, ValueError):
+            return 0
+    latest_runtime = max(
+        _latest_story_system_chapter(project_root),
+        _latest_draft_chapter(project_root),
+    )
+    if latest_runtime > latest_accepted_chapter:
+        return latest_runtime
+    return latest_accepted_chapter + 1 if latest_accepted_chapter >= 0 else 0
+
+
+def _volume_num(project_root: Path, chapter: int) -> int:
+    if chapter <= 0:
+        return 1
+    try:
+        return volume_num_for_chapter_from_state(project_root, chapter) or volume_num_for_chapter(chapter)
+    except Exception:
+        return volume_num_for_chapter(chapter)
+
+
+def contract_files_for_chapter(project_root: Path, chapter: int) -> dict[str, Path]:
+    volume = _volume_num(project_root, chapter)
+    story_root = project_root / ".story-system"
+    return {
+        "master": story_root / "MASTER_SETTING.json",
+        "volume": story_root / "volumes" / f"volume_{volume:03d}.json",
+        "chapter": story_root / "chapters" / f"chapter_{chapter:03d}.json",
+        "review": story_root / "reviews" / f"chapter_{chapter:03d}.review.json",
+    }
+
+
+def missing_contract_files(project_root: Path, chapter: int) -> tuple[str, ...]:
+    if chapter <= 0:
+        return tuple(str(path.relative_to(project_root)) for path in contract_files_for_chapter(project_root, 1).values())
+    missing: list[str] = []
+    for path in contract_files_for_chapter(project_root, chapter).values():
+        if not path.is_file():
+            missing.append(str(path.relative_to(project_root)))
+    return tuple(missing)
+
+
+def missing_commit_artifacts(project_root: Path) -> tuple[str, ...]:
+    missing: list[str] = []
+    for rel in COMMIT_ARTIFACT_FILES:
+        if not (project_root / rel).is_file():
+            missing.append(rel)
+    return tuple(missing)
+
+
+def missing_init_dirs(project_root: Path) -> tuple[str, ...]:
+    return tuple(rel for rel in INIT_REQUIRED_DIRS if not (project_root / rel).is_dir())
+
+
+def missing_init_files(project_root: Path) -> tuple[str, ...]:
+    return tuple(rel for rel in INIT_REQUIRED_FILES if not (project_root / rel).is_file())
+
+
+def has_projection_blocker(commit: ChapterCommitInfo | None) -> bool:
+    if commit is None:
+        return False
+    return any(
+        str(value).startswith("failed:") or str(value) == "pending"
+        for value in commit.projection_status.values()
+    )
+
+
+def resolve_project_phase(project_root: str | Path | None, chapter: int | None = None) -> ProjectPhaseSnapshot:
+    if project_root is None:
+        return ProjectPhaseSnapshot(
+            project_root="",
+            phase=PHASE_NO_PROJECT,
+            target_chapter=0,
+            latest_accepted_chapter=0,
+            blocking=("project_root_missing",),
+        )
+
+    root = Path(project_root)
+    state_path = root / ".webnovel" / "state.json"
+    if not state_path.is_file():
+        return ProjectPhaseSnapshot(
+            project_root=str(root),
+            phase=PHASE_NO_PROJECT,
+            target_chapter=0,
+            latest_accepted_chapter=0,
+            blocking=("missing .webnovel/state.json",),
+        )
+
+    state_chapter, state_error = _state_current_chapter(root)
+    commits = _scan_commits(root)
+    latest_commit = max(commits, key=lambda item: item.chapter) if commits else None
+    accepted = [item.chapter for item in commits if item.status == "accepted"]
+    latest_accepted = max(accepted or [0])
+    target = _target_chapter(root, chapter, latest_accepted_chapter=latest_accepted)
+
+    init_dirs_missing = missing_init_dirs(root)
+    init_files_missing = missing_init_files(root)
+    contract_missing = missing_contract_files(root, target)
+    artifacts_missing = missing_commit_artifacts(root)
+    draft_path = find_chapter_file(root, target) if target > 0 else None
+    draft_file = str(draft_path) if draft_path else ""
+
+    warnings: list[str] = []
+    blocking: list[str] = []
+    if state_error:
+        blocking.append(f"state_json_{state_error}")
+    if state_chapter > latest_accepted:
+        warnings.append("state_projection_ahead_of_latest_accepted_commit")
+
+    if has_projection_blocker(latest_commit):
+        phase = PHASE_PROJECTION_FAILED
+        latest_statuses = [str(value) for value in (latest_commit.projection_status or {}).values()]
+        blocking.append(
+            "latest_commit_projection_incomplete"
+            if any(value == "pending" for value in latest_statuses)
+            else "latest_commit_projection_failed"
+        )
+    elif init_dirs_missing or init_files_missing:
+        phase = PHASE_INIT_SCAFFOLDED
+        blocking.extend([f"missing_init_dir:{rel}" for rel in init_dirs_missing])
+        blocking.extend([f"missing_init_file:{rel}" for rel in init_files_missing])
+    elif latest_commit and latest_commit.chapter >= target and latest_commit.status in {"accepted", "rejected"}:
+        phase = PHASE_CHAPTER_COMMITTED
+    elif draft_file and not artifacts_missing:
+        phase = PHASE_READY_TO_COMMIT
+    elif draft_file:
+        phase = PHASE_DRAFT_IN_PROGRESS
+    elif not contract_missing:
+        phase = PHASE_CHAPTER_CONTRACT_READY
+    elif (root / ".story-system" / "MASTER_SETTING.json").is_file() or any((root / "大纲").glob("第*卷*大纲.md")):
+        phase = PHASE_PLAN_IN_PROGRESS
+    else:
+        phase = PHASE_INIT_READY
+
+    return ProjectPhaseSnapshot(
+        project_root=str(root),
+        phase=phase,
+        target_chapter=target,
+        latest_accepted_chapter=latest_accepted,
+        latest_commit=latest_commit,
+        state_current_chapter=state_chapter,
+        missing_init_files=init_files_missing,
+        missing_init_dirs=init_dirs_missing,
+        missing_contract_files=contract_missing,
+        missing_commit_artifacts=artifacts_missing,
+        draft_file=draft_file,
+        blocking=tuple(blocking),
+        warnings=tuple(warnings),
+    )

+ 120 - 0
webnovel-writer/scripts/data_modules/project_status.py

@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import Any
+
+from .project_phase import (
+    PHASE_CHAPTER_CONTRACT_READY,
+    PHASE_CHAPTER_COMMITTED,
+    PHASE_DRAFT_IN_PROGRESS,
+    PHASE_INIT_READY,
+    PHASE_INIT_SCAFFOLDED,
+    PHASE_NO_PROJECT,
+    PHASE_PLAN_IN_PROGRESS,
+    PHASE_PROJECTION_FAILED,
+    PHASE_READY_TO_COMMIT,
+    ProjectPhaseSnapshot,
+    resolve_project_phase,
+)
+
+
+SCHEMA_VERSION = "webnovel-project-status/v1"
+
+
+def _project_title(project_root: Path) -> str:
+    state_path = project_root / ".webnovel" / "state.json"
+    try:
+        state = json.loads(state_path.read_text(encoding="utf-8"))
+    except Exception:
+        return ""
+    if not isinstance(state, dict):
+        return ""
+    project_info = state.get("project_info") if isinstance(state.get("project_info"), dict) else {}
+    project = state.get("project") if isinstance(state.get("project"), dict) else {}
+    return str(project_info.get("title") or project.get("title") or "").strip()
+
+
+def next_action_for_phase(snapshot: ProjectPhaseSnapshot) -> str:
+    phase = snapshot.phase
+    target = snapshot.target_chapter
+    if phase == PHASE_NO_PROJECT:
+        return "run /webnovel-init or webnovel.py use <project_root>"
+    if phase == PHASE_INIT_SCAFFOLDED:
+        return "run webnovel.py doctor --format text and fix missing init files"
+    if phase == PHASE_INIT_READY:
+        return "run /webnovel-plan or refresh Story System contracts"
+    if phase == PHASE_PLAN_IN_PROGRESS:
+        return f"finish planning and emit runtime contracts for chapter {target}"
+    if phase == PHASE_CHAPTER_CONTRACT_READY:
+        return f"run /webnovel-write {target}"
+    if phase == PHASE_DRAFT_IN_PROGRESS:
+        return f"finish review/data artifacts for chapter {target}"
+    if phase == PHASE_READY_TO_COMMIT:
+        return f"run webnovel.py chapter-commit --chapter {target}"
+    if phase == PHASE_CHAPTER_COMMITTED:
+        return f"continue with chapter {snapshot.latest_accepted_chapter + 1}"
+    if phase == PHASE_PROJECTION_FAILED:
+        return "inspect projection_log / projection_status and repair failed or pending projection"
+    return "run webnovel.py doctor --format text"
+
+
+def build_project_status(project_root: str | Path | None, chapter: int | None = None) -> dict[str, Any]:
+    snapshot = resolve_project_phase(project_root, chapter=chapter)
+    root = Path(snapshot.project_root) if snapshot.project_root else None
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "project_root": snapshot.project_root,
+        "project": _project_title(root) if root else "",
+        "latest_accepted_chapter": snapshot.latest_accepted_chapter,
+        "target_chapter": snapshot.target_chapter,
+        "phase": snapshot.phase,
+        "blocking": list(snapshot.blocking),
+        "warnings": list(snapshot.warnings),
+        "next_action": next_action_for_phase(snapshot),
+        "evidence": snapshot.to_dict(),
+    }
+
+
+def format_project_status(report: dict[str, Any], output_format: str = "summary") -> str:
+    if output_format == "json":
+        return json.dumps(report, ensure_ascii=False, indent=2)
+
+    project = report.get("project") or "(未命名项目)"
+    root = report.get("project_root") or "(未解析)"
+    lines = [
+        f"project: {project}",
+        f"root: {root}",
+        f"phase: {report.get('phase')}",
+        f"latest_accepted_chapter: {report.get('latest_accepted_chapter')}",
+        f"target_chapter: {report.get('target_chapter')}",
+        f"next_action: {report.get('next_action')}",
+    ]
+    blocking = report.get("blocking") or []
+    warnings = report.get("warnings") or []
+    if blocking:
+        lines.append("blocking:")
+        lines.extend(f"- {item}" for item in blocking)
+    if warnings:
+        lines.append("warnings:")
+        lines.extend(f"- {item}" for item in warnings)
+    return "\n".join(lines)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Build short machine-readable webnovel project status")
+    parser.add_argument("--project-root", default="", help="书项目根目录")
+    parser.add_argument("--chapter", type=int, default=None, help="目标章节号")
+    parser.add_argument("--format", choices=["summary", "json"], default="summary")
+    args = parser.parse_args()
+
+    report = build_project_status(args.project_root or None, chapter=args.chapter)
+    print(format_project_status(report, args.format))
+    raise SystemExit(0)
+
+
+if __name__ == "__main__":
+    main()

+ 150 - 0
webnovel-writer/scripts/data_modules/projection_log.py

@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import hashlib
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+
+SCHEMA_VERSION = "webnovel-projection-log/v1"
+PROJECTION_LOG_REL = Path(".webnovel") / "projection_log.jsonl"
+
+
+def projection_log_path(project_root: str | Path) -> Path:
+    return Path(project_root) / PROJECTION_LOG_REL
+
+
+def commit_hash(commit_payload: dict[str, Any]) -> str:
+    raw = json.dumps(commit_payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+    return hashlib.sha256(raw.encode("utf-8")).hexdigest()
+
+
+def _now_iso() -> str:
+    return datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+
+def _overall_status(writers: dict[str, dict[str, Any]]) -> str:
+    statuses = {str(item.get("status") or "") for item in writers.values()}
+    if any(status.startswith("failed") or status == "failed" for status in statuses):
+        return "failed"
+    if statuses and statuses <= {"skipped"}:
+        return "skipped"
+    if "pending" in statuses:
+        return "pending"
+    return "done"
+
+
+def build_projection_run(
+    *,
+    project_root: str | Path,
+    commit_payload: dict[str, Any],
+    writer_results: dict[str, dict[str, Any]],
+    commit_path: str | Path | None = None,
+) -> dict[str, Any]:
+    meta = commit_payload.get("meta") if isinstance(commit_payload, dict) else {}
+    chapter = int((meta or {}).get("chapter") or 0)
+    if commit_path is None and chapter > 0:
+        commit_path = Path(project_root) / ".story-system" / "commits" / f"chapter_{chapter:03d}.commit.json"
+    writers = {str(name): dict(result) for name, result in writer_results.items()}
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "run_id": uuid4().hex,
+        "created_at": _now_iso(),
+        "chapter": chapter,
+        "commit_path": str(commit_path or ""),
+        "commit_hash": commit_hash(commit_payload),
+        "commit_status": str((meta or {}).get("status") or ""),
+        "status": _overall_status(writers),
+        "writers": writers,
+        "projection_status": dict(commit_payload.get("projection_status") or {}),
+    }
+
+
+def projection_status_from_run(run: dict[str, Any] | None) -> dict[str, str]:
+    if not isinstance(run, dict):
+        return {}
+    writers = run.get("writers")
+    if isinstance(writers, dict):
+        statuses = {
+            str(name): str(result.get("status") or "")
+            for name, result in writers.items()
+            if isinstance(result, dict) and result.get("status")
+        }
+        if statuses:
+            return statuses
+    projection_status = run.get("projection_status")
+    if isinstance(projection_status, dict):
+        return {str(name): str(status) for name, status in projection_status.items()}
+    return {}
+
+
+def projection_run_failed(run: dict[str, Any] | None) -> bool:
+    if not isinstance(run, dict):
+        return False
+    if str(run.get("status") or "").startswith("failed"):
+        return True
+    return any(status.startswith("failed") for status in projection_status_from_run(run).values())
+
+
+def projection_run_pending(run: dict[str, Any] | None) -> bool:
+    if not isinstance(run, dict):
+        return False
+    if str(run.get("status") or "") == "pending":
+        return True
+    return any(status == "pending" for status in projection_status_from_run(run).values())
+
+
+def append_projection_run(
+    project_root: str | Path,
+    commit_payload: dict[str, Any],
+    writer_results: dict[str, dict[str, Any]],
+    *,
+    commit_path: str | Path | None = None,
+) -> dict[str, Any]:
+    record = build_projection_run(
+        project_root=project_root,
+        commit_payload=commit_payload,
+        writer_results=writer_results,
+        commit_path=commit_path,
+    )
+    path = projection_log_path(project_root)
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with path.open("a", encoding="utf-8") as handle:
+        handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True))
+        handle.write("\n")
+    return record
+
+
+def read_projection_runs(project_root: str | Path, *, chapter: int | None = None) -> list[dict[str, Any]]:
+    path = projection_log_path(project_root)
+    if not path.is_file():
+        return []
+    records: list[dict[str, Any]] = []
+    for line in path.read_text(encoding="utf-8").splitlines():
+        raw = line.strip()
+        if not raw:
+            continue
+        try:
+            payload = json.loads(raw)
+        except json.JSONDecodeError:
+            continue
+        if not isinstance(payload, dict):
+            continue
+        if chapter is not None:
+            try:
+                record_chapter = int(payload.get("chapter") or 0)
+            except (TypeError, ValueError):
+                continue
+            if record_chapter != int(chapter):
+                continue
+        records.append(payload)
+    return records
+
+
+def latest_projection_run(project_root: str | Path, *, chapter: int | None = None) -> dict[str, Any] | None:
+    records = read_projection_runs(project_root, chapter=chapter)
+    return records[-1] if records else None

+ 163 - 0
webnovel-writer/scripts/data_modules/projections.py

@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import Any
+
+from .chapter_commit_service import ChapterCommitService
+from .projection_log import latest_projection_run
+
+
+SCHEMA_VERSION = "webnovel-projections/v1"
+DEFAULT_PROJECTION_STATUS = {
+    "state": "pending",
+    "index": "pending",
+    "summary": "pending",
+    "memory": "pending",
+    "vector": "pending",
+}
+
+
+def _commit_path(project_root: Path, chapter: int) -> Path:
+    return project_root / ".story-system" / "commits" / f"chapter_{chapter:03d}.commit.json"
+
+
+def _read_commit(path: Path) -> tuple[dict[str, Any], str]:
+    if not path.is_file():
+        return {}, "missing_commit"
+    try:
+        payload = json.loads(path.read_text(encoding="utf-8"))
+    except json.JSONDecodeError as exc:
+        return {}, f"invalid_json:{exc}"
+    except OSError as exc:
+        return {}, f"read_error:{exc}"
+    if not isinstance(payload, dict):
+        return {}, "commit_not_object"
+    payload.setdefault("projection_status", dict(DEFAULT_PROJECTION_STATUS))
+    for key, value in DEFAULT_PROJECTION_STATUS.items():
+        payload["projection_status"].setdefault(key, value)
+    return payload, ""
+
+
+def _projection_failed(payload: dict[str, Any]) -> bool:
+    projection_status = payload.get("projection_status") or {}
+    if not isinstance(projection_status, dict):
+        return True
+    return any(str(value).startswith("failed:") or str(value) == "pending" for value in projection_status.values())
+
+
+def retry_projection(project_root: str | Path, *, chapter: int) -> dict[str, Any]:
+    root = Path(project_root)
+    path = _commit_path(root, chapter)
+    payload, error = _read_commit(path)
+    if error:
+        return {
+            "schema_version": SCHEMA_VERSION,
+            "action": "retry",
+            "ok": False,
+            "project_root": str(root),
+            "chapter": chapter,
+            "error": error,
+            "commit_path": str(path),
+            "projection_status": {},
+            "latest_projection_run": None,
+        }
+
+    projected = ChapterCommitService(root).apply_projection_writers(payload)
+    latest_run = latest_projection_run(root, chapter=chapter)
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "action": "retry",
+        "ok": not _projection_failed(projected),
+        "project_root": str(root),
+        "chapter": chapter,
+        "error": "",
+        "commit_path": str(path),
+        "projection_status": dict(projected.get("projection_status") or {}),
+        "latest_projection_run": latest_run,
+    }
+
+
+def replay_projections(project_root: str | Path, *, start_chapter: int, end_chapter: int) -> dict[str, Any]:
+    root = Path(project_root)
+    if start_chapter <= 0 or end_chapter <= 0 or start_chapter > end_chapter:
+        return {
+            "schema_version": SCHEMA_VERSION,
+            "action": "replay",
+            "ok": False,
+            "project_root": str(root),
+            "start_chapter": start_chapter,
+            "end_chapter": end_chapter,
+            "error": "invalid_chapter_range",
+            "results": [],
+        }
+    results = [retry_projection(root, chapter=chapter) for chapter in range(start_chapter, end_chapter + 1)]
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "action": "replay",
+        "ok": all(item.get("ok") for item in results),
+        "project_root": str(root),
+        "start_chapter": start_chapter,
+        "end_chapter": end_chapter,
+        "error": "",
+        "results": results,
+    }
+
+
+def format_projection_report(report: dict[str, Any], output_format: str = "json") -> str:
+    if output_format == "json":
+        return json.dumps(report, ensure_ascii=False, indent=2)
+    status = "OK" if report.get("ok") else "ERROR"
+    if report.get("action") == "retry":
+        return "\n".join(
+            [
+                f"{status} projections retry",
+                f"chapter: {report.get('chapter')}",
+                f"commit_path: {report.get('commit_path')}",
+                f"projection_status: {report.get('projection_status')}",
+                f"error: {report.get('error') or ''}",
+            ]
+        )
+    lines = [
+        f"{status} projections replay",
+        f"range: {report.get('start_chapter')}-{report.get('end_chapter')}",
+        f"error: {report.get('error') or ''}",
+    ]
+    for item in report.get("results") or []:
+        lines.append(f"- chapter {item.get('chapter')}: {'OK' if item.get('ok') else 'ERROR'} {item.get('projection_status') or item.get('error')}")
+    return "\n".join(lines)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Retry or replay webnovel projections from existing commits")
+    parser.add_argument("--project-root", required=True)
+    sub = parser.add_subparsers(dest="action", required=True)
+
+    retry = sub.add_parser("retry")
+    retry.add_argument("--chapter", type=int, required=True)
+    retry.add_argument("--format", choices=["json", "text"], default="json")
+
+    replay = sub.add_parser("replay")
+    replay.add_argument("--from-chapter", type=int, required=True)
+    replay.add_argument("--to-chapter", type=int, required=True)
+    replay.add_argument("--format", choices=["json", "text"], default="json")
+
+    args = parser.parse_args()
+    if args.action == "retry":
+        report = retry_projection(args.project_root, chapter=args.chapter)
+        print(format_projection_report(report, args.format))
+        raise SystemExit(0 if report.get("ok") else 1)
+    report = replay_projections(
+        args.project_root,
+        start_chapter=args.from_chapter,
+        end_chapter=args.to_chapter,
+    )
+    print(format_projection_report(report, args.format))
+    raise SystemExit(0 if report.get("ok") else 1)
+
+
+if __name__ == "__main__":
+    main()

+ 161 - 0
webnovel-writer/scripts/data_modules/tests/test_artifact_validator.py

@@ -0,0 +1,161 @@
+#!/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[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+
+_ensure_scripts_on_path()
+
+from data_modules.artifact_validator import (  # noqa: E402
+    ARTIFACT_SCHEMAS,
+    ERROR_BLOCKING_REVIEW,
+    ERROR_MISSED_OUTLINE_NODE,
+    ERROR_MISSING,
+    ERROR_PENDING_DISAMBIGUATION,
+    ERROR_PROJECTION_FAILURE,
+    ERROR_SCHEMA,
+    validate_chapter_commit,
+    validate_commit_artifact_files,
+    validate_disambiguation_result,
+    validate_extraction_result,
+    validate_fulfillment_result,
+    validate_review_result,
+)
+from data_modules.chapter_commit_schema import (  # noqa: E402
+    DisambiguationResult,
+    ExtractionResult,
+    FulfillmentResult,
+    ReviewResult,
+)
+
+
+def _write_json(path: Path, payload: dict) -> Path:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
+    return path
+
+
+def test_artifact_validator_uses_chapter_commit_schema_as_authority():
+    assert ARTIFACT_SCHEMAS["review_result"] is ReviewResult
+    assert ARTIFACT_SCHEMAS["fulfillment_result"] is FulfillmentResult
+    assert ARTIFACT_SCHEMAS["disambiguation_result"] is DisambiguationResult
+    assert ARTIFACT_SCHEMAS["extraction_result"] is ExtractionResult
+
+
+def test_artifact_validator_reports_missing_artifact(tmp_path):
+    report = validate_review_result(tmp_path / "missing.json")
+
+    assert report["ok"] is False
+    assert report["errors"][0]["type"] == ERROR_MISSING
+
+
+def test_artifact_validator_reports_schema_errors_for_wrapped_payloads(tmp_path):
+    path = _write_json(tmp_path / "fulfillment_result.json", {"fulfillment": {"missed_nodes": []}})
+
+    report = validate_fulfillment_result(path)
+
+    assert report["ok"] is False
+    assert report["errors"][0]["type"] == ERROR_SCHEMA
+    assert "nested under fulfillment" in report["errors"][0]["message"]
+
+
+def test_artifact_validator_reports_policy_blockers(tmp_path):
+    review = _write_json(tmp_path / "review_results.json", {"blocking_count": 1})
+    fulfillment = _write_json(
+        tmp_path / "fulfillment_result.json",
+        {
+            "planned_nodes": ["A"],
+            "covered_nodes": [],
+            "missed_nodes": ["A"],
+            "extra_nodes": [],
+        },
+    )
+    disambiguation = _write_json(tmp_path / "disambiguation_result.json", {"pending": [{"mention": "宗主"}]})
+
+    assert validate_review_result(review)["errors"][0]["type"] == ERROR_BLOCKING_REVIEW
+    assert validate_fulfillment_result(fulfillment)["errors"][0]["type"] == ERROR_MISSED_OUTLINE_NODE
+    assert validate_disambiguation_result(disambiguation)["errors"][0]["type"] == ERROR_PENDING_DISAMBIGUATION
+
+
+def test_artifact_validator_accepts_valid_extraction(tmp_path):
+    path = _write_json(
+        tmp_path / "extraction_result.json",
+        {
+            "accepted_events": [],
+            "state_deltas": [],
+            "entity_deltas": [],
+            "entities_appeared": [],
+            "scenes": [],
+            "summary_text": "摘要",
+        },
+    )
+
+    report = validate_extraction_result(path)
+
+    assert report["ok"] is True
+    assert report["payload"]["summary_text"] == "摘要"
+
+
+def test_validate_commit_artifact_files_merges_reports(tmp_path):
+    review = _write_json(tmp_path / "review_results.json", {"blocking_count": 0})
+    fulfillment = _write_json(
+        tmp_path / "fulfillment_result.json",
+        {"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
+    )
+    disambiguation = _write_json(tmp_path / "disambiguation_result.json", {"pending": []})
+    extraction = _write_json(
+        tmp_path / "extraction_result.json",
+        {"accepted_events": [], "state_deltas": [], "entity_deltas": [], "summary_text": "摘要"},
+    )
+
+    report = validate_commit_artifact_files(
+        review_result=review,
+        fulfillment_result=fulfillment,
+        disambiguation_result=disambiguation,
+        extraction_result=extraction,
+    )
+
+    assert report["ok"] is True
+    assert set(report["payloads"]) == {
+        "review_result",
+        "fulfillment_result",
+        "disambiguation_result",
+        "extraction_result",
+    }
+
+
+def test_validate_chapter_commit_reports_projection_failure(tmp_path):
+    commit = _write_json(
+        tmp_path / "chapter_001.commit.json",
+        {
+            "meta": {"chapter": 1, "status": "accepted"},
+            "review_result": {"blocking_count": 0},
+            "fulfillment_result": {
+                "planned_nodes": [],
+                "covered_nodes": [],
+                "missed_nodes": [],
+                "extra_nodes": [],
+            },
+            "disambiguation_result": {"pending": []},
+            "extraction_result": {
+                "accepted_events": [],
+                "state_deltas": [],
+                "entity_deltas": [],
+                "summary_text": "摘要",
+            },
+            "projection_status": {"state": "done", "index": "failed:locked"},
+        },
+    )
+
+    report = validate_chapter_commit(commit)
+
+    assert report["ok"] is False
+    assert any(item["type"] == ERROR_PROJECTION_FAILURE for item in report["errors"])

+ 16 - 1
webnovel-writer/scripts/data_modules/tests/test_dashboard_app.py

@@ -376,6 +376,18 @@ def test_dashboard_chapter_trend_endpoint_returns_recent_window(monkeypatch, tmp
 def test_dashboard_commits_and_contract_summary_endpoints(monkeypatch, tmp_path):
     project_root = tmp_path / "book"
     _build_project_data(project_root)
+    from data_modules.projection_log import append_projection_run
+
+    commit_path = project_root / ".story-system" / "commits" / "chapter_002.commit.json"
+    logged_payload = json.loads(commit_path.read_text(encoding="utf-8"))
+    logged_payload["projection_status"] = dict(logged_payload["projection_status"])
+    logged_payload["projection_status"]["vector"] = "failed:timeout"
+    append_projection_run(
+        project_root,
+        logged_payload,
+        {"vector": {"status": "failed:timeout", "error": "timeout"}},
+        commit_path=commit_path,
+    )
     client = _create_dashboard_client(monkeypatch, project_root)
 
     commits_response = client.get("/api/commits", params={"limit": 2})
@@ -383,7 +395,10 @@ def test_dashboard_commits_and_contract_summary_endpoints(monkeypatch, tmp_path)
     commits_payload = commits_response.json()
     assert [item["chapter"] for item in commits_payload["items"]] == [3, 2]
     assert commits_payload["items"][0]["status"] == "rejected"
-    assert commits_payload["items"][1]["projection_status"]["vector"] == "done"
+    assert commits_payload["items"][0]["projection_source"] == "commit"
+    assert commits_payload["items"][1]["projection_source"] == "projection_log"
+    assert commits_payload["items"][1]["projection_status"]["vector"] == "failed:timeout"
+    assert commits_payload["items"][1]["projection_run"]["run_id"]
 
     contracts_response = client.get("/api/contracts/summary")
     assert contracts_response.status_code == 200

+ 112 - 0
webnovel-writer/scripts/data_modules/tests/test_doctor.py

@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import sys
+from pathlib import Path
+
+from .test_project_phase import _make_contracts, _make_init_ready
+from .test_project_phase import _write_json
+
+
+def _ensure_scripts_on_path() -> None:
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+
+_ensure_scripts_on_path()
+
+import data_modules.doctor as doctor_module  # noqa: E402
+from data_modules.projection_log import append_projection_run  # noqa: E402
+
+
+def test_doctor_init_ready_does_not_require_story_contracts(tmp_path, monkeypatch):
+    _make_init_ready(tmp_path)
+    monkeypatch.setattr(doctor_module, "_python_checks", lambda: [])
+
+    report = doctor_module.build_doctor_report(tmp_path)
+
+    assert report["ok"] is True
+    assert report["phase"] == "init_ready"
+    assert not [item for item in report["checks"] if str(item["id"]).startswith("file.contract.")]
+
+
+def test_doctor_missing_init_file_blocks_with_repair(tmp_path, monkeypatch):
+    _make_init_ready(tmp_path)
+    (tmp_path / "大纲" / "总纲.md").unlink()
+    monkeypatch.setattr(doctor_module, "_python_checks", lambda: [])
+
+    report = doctor_module.build_doctor_report(tmp_path)
+
+    assert report["ok"] is False
+    matches = [item for item in report["checks"] if item["id"] == "file.required.大纲/总纲.md"]
+    assert matches
+    assert matches[0]["status"] == "error"
+    assert matches[0]["repair"]
+
+
+def test_doctor_checks_contracts_after_story_system_starts(tmp_path, monkeypatch):
+    _make_init_ready(tmp_path)
+    _make_contracts(tmp_path, chapter=1)
+    (tmp_path / ".story-system" / "reviews" / "chapter_001.review.json").unlink()
+    monkeypatch.setattr(doctor_module, "_python_checks", lambda: [])
+
+    report = doctor_module.build_doctor_report(tmp_path)
+
+    assert report["ok"] is False
+    contract_checks = [item for item in report["checks"] if item["id"] == "file.contract.review"]
+    assert contract_checks
+    assert contract_checks[0]["status"] == "error"
+
+
+def test_doctor_no_project_reports_repair(monkeypatch):
+    monkeypatch.setattr(doctor_module, "_python_checks", lambda: [])
+
+    report = doctor_module.build_doctor_report(None)
+
+    assert report["ok"] is False
+    assert report["phase"] == "no_project"
+    assert report["recommended_actions"]
+
+
+def test_doctor_warns_when_old_project_has_commit_without_projection_log(tmp_path, monkeypatch):
+    _make_init_ready(tmp_path)
+    _write_json(
+        tmp_path / ".story-system" / "commits" / "chapter_001.commit.json",
+        {
+            "meta": {"chapter": 1, "status": "accepted"},
+            "projection_status": {"state": "done"},
+        },
+    )
+    monkeypatch.setattr(doctor_module, "_python_checks", lambda: [])
+
+    report = doctor_module.build_doctor_report(tmp_path)
+
+    assert report["ok"] is True
+    matches = [item for item in report["checks"] if item["id"] == "projection_log.present"]
+    assert matches
+    assert matches[0]["status"] == "warning"
+
+
+def test_doctor_blocks_pending_projection_log_run(tmp_path, monkeypatch):
+    _make_init_ready(tmp_path)
+    commit_payload = {
+        "meta": {"chapter": 1, "status": "accepted"},
+        "projection_status": {"state": "pending"},
+    }
+    commit_path = tmp_path / ".story-system" / "commits" / "chapter_001.commit.json"
+    _write_json(commit_path, commit_payload)
+    append_projection_run(
+        tmp_path,
+        commit_payload,
+        {"state": {"status": "pending"}},
+        commit_path=commit_path,
+    )
+    monkeypatch.setattr(doctor_module, "_python_checks", lambda: [])
+
+    report = doctor_module.build_doctor_report(tmp_path)
+
+    matches = [item for item in report["checks"] if item["id"] == "projection_log.latest_run"]
+    assert matches
+    assert matches[0]["status"] == "error"
+    assert report["ok"] is False

+ 169 - 0
webnovel-writer/scripts/data_modules/tests/test_project_phase.py

@@ -0,0 +1,169 @@
+#!/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[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+
+_ensure_scripts_on_path()
+
+from data_modules.project_phase import (  # noqa: E402
+    INIT_REQUIRED_DIRS,
+    INIT_REQUIRED_FILES,
+    PHASE_CHAPTER_CONTRACT_READY,
+    PHASE_DRAFT_IN_PROGRESS,
+    PHASE_INIT_READY,
+    PHASE_INIT_SCAFFOLDED,
+    PHASE_PROJECTION_FAILED,
+    PHASE_READY_TO_COMMIT,
+    COMMIT_ARTIFACT_FILES,
+    resolve_project_phase,
+)
+from data_modules.projection_log import append_projection_run  # 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), encoding="utf-8")
+
+
+def _make_init_ready(project_root: Path) -> None:
+    for rel in INIT_REQUIRED_DIRS:
+        (project_root / rel).mkdir(parents=True, exist_ok=True)
+    for rel in INIT_REQUIRED_FILES:
+        path = project_root / rel
+        path.parent.mkdir(parents=True, exist_ok=True)
+        if rel.endswith(".json"):
+            _write_json(
+                path,
+                {
+                    "project_info": {"title": "测试书", "genre": "玄幻"},
+                    "progress": {"current_chapter": 0},
+                },
+            )
+        else:
+            path.write_text("placeholder\n", encoding="utf-8")
+
+
+def _make_contracts(project_root: Path, chapter: int = 1) -> None:
+    _write_json(project_root / ".story-system" / "MASTER_SETTING.json", {"meta": {"contract_type": "MASTER_SETTING"}})
+    _write_json(project_root / ".story-system" / "volumes" / "volume_001.json", {"meta": {"volume": 1}})
+    _write_json(project_root / ".story-system" / "chapters" / f"chapter_{chapter:03d}.json", {"meta": {"chapter": chapter}})
+    _write_json(
+        project_root / ".story-system" / "reviews" / f"chapter_{chapter:03d}.review.json",
+        {"meta": {"chapter": chapter}},
+    )
+
+
+def test_project_phase_reports_init_scaffolded_when_core_files_missing(tmp_path):
+    _write_json(tmp_path / ".webnovel" / "state.json", {"project_info": {}, "progress": {}})
+
+    snapshot = resolve_project_phase(tmp_path)
+
+    assert snapshot.phase == PHASE_INIT_SCAFFOLDED
+    assert "大纲/总纲.md" in snapshot.missing_init_files
+    assert snapshot.blocking
+
+
+def test_project_phase_reports_init_ready_after_init_scaffold(tmp_path):
+    _make_init_ready(tmp_path)
+
+    snapshot = resolve_project_phase(tmp_path)
+
+    assert snapshot.phase == PHASE_INIT_READY
+    assert snapshot.target_chapter == 1
+    assert snapshot.blocking == ()
+
+
+def test_project_phase_detects_chapter_contract_ready(tmp_path):
+    _make_init_ready(tmp_path)
+    _make_contracts(tmp_path, chapter=1)
+
+    snapshot = resolve_project_phase(tmp_path)
+
+    assert snapshot.phase == PHASE_CHAPTER_CONTRACT_READY
+    assert snapshot.missing_contract_files == ()
+
+
+def test_project_phase_detects_draft_and_ready_to_commit(tmp_path):
+    _make_init_ready(tmp_path)
+    _make_contracts(tmp_path, chapter=1)
+    (tmp_path / "正文" / "第0001章.md").write_text("正文草稿\n", encoding="utf-8")
+
+    draft_snapshot = resolve_project_phase(tmp_path)
+    assert draft_snapshot.phase == PHASE_DRAFT_IN_PROGRESS
+
+    for rel in COMMIT_ARTIFACT_FILES:
+        path = tmp_path / rel
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_text("{}", encoding="utf-8")
+
+    ready_snapshot = resolve_project_phase(tmp_path)
+    assert ready_snapshot.phase == PHASE_READY_TO_COMMIT
+
+
+def test_project_phase_detects_projection_failed(tmp_path):
+    _make_init_ready(tmp_path)
+    _write_json(
+        tmp_path / ".story-system" / "commits" / "chapter_001.commit.json",
+        {
+            "meta": {"chapter": 1, "status": "accepted"},
+            "projection_status": {"state": "done", "index": "failed:locked"},
+        },
+    )
+
+    snapshot = resolve_project_phase(tmp_path)
+
+    assert snapshot.phase == PHASE_PROJECTION_FAILED
+    assert "latest_commit_projection_failed" in snapshot.blocking
+
+
+def test_project_phase_prefers_projection_log_over_commit_status(tmp_path):
+    _make_init_ready(tmp_path)
+    commit_path = tmp_path / ".story-system" / "commits" / "chapter_001.commit.json"
+    commit_payload = {
+        "meta": {"chapter": 1, "status": "accepted"},
+        "projection_status": {"state": "done", "index": "done", "vector": "done"},
+    }
+    _write_json(commit_path, commit_payload)
+    append_projection_run(
+        tmp_path,
+        commit_payload,
+        {"vector": {"status": "failed:timeout", "error": "timeout"}},
+        commit_path=commit_path,
+    )
+
+    snapshot = resolve_project_phase(tmp_path)
+
+    assert snapshot.phase == PHASE_PROJECTION_FAILED
+    assert snapshot.latest_commit is not None
+    assert snapshot.latest_commit.projection_source == "projection_log"
+    assert snapshot.latest_commit.projection_status["vector"] == "failed:timeout"
+
+
+def test_project_phase_treats_projection_log_pending_as_blocking(tmp_path):
+    _make_init_ready(tmp_path)
+    commit_path = tmp_path / ".story-system" / "commits" / "chapter_001.commit.json"
+    commit_payload = {
+        "meta": {"chapter": 1, "status": "accepted"},
+        "projection_status": {"state": "done"},
+    }
+    _write_json(commit_path, commit_payload)
+    append_projection_run(
+        tmp_path,
+        commit_payload,
+        {"state": {"status": "pending"}},
+        commit_path=commit_path,
+    )
+
+    snapshot = resolve_project_phase(tmp_path)
+
+    assert snapshot.phase == PHASE_PROJECTION_FAILED
+    assert "latest_commit_projection_incomplete" in snapshot.blocking

+ 54 - 0
webnovel-writer/scripts/data_modules/tests/test_project_status.py

@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import json
+import sys
+from pathlib import Path
+
+from .test_project_phase import _make_contracts, _make_init_ready
+
+
+def _ensure_scripts_on_path() -> None:
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+
+_ensure_scripts_on_path()
+
+from data_modules.project_status import (  # noqa: E402
+    SCHEMA_VERSION,
+    build_project_status,
+    format_project_status,
+)
+
+
+def test_project_status_json_shape(tmp_path):
+    _make_init_ready(tmp_path)
+    _make_contracts(tmp_path, chapter=1)
+
+    report = build_project_status(tmp_path)
+
+    assert report["schema_version"] == SCHEMA_VERSION
+    assert report["project"] == "测试书"
+    assert report["phase"] == "chapter_contract_ready"
+    assert report["target_chapter"] == 1
+    assert report["next_action"] == "run /webnovel-write 1"
+
+
+def test_project_status_summary_is_short_and_machine_source_is_json(tmp_path):
+    _make_init_ready(tmp_path)
+    report = build_project_status(tmp_path)
+
+    summary = format_project_status(report, "summary")
+    payload = json.loads(format_project_status(report, "json"))
+
+    assert "phase: init_ready" in summary
+    assert payload["schema_version"] == SCHEMA_VERSION
+
+
+def test_project_status_handles_no_project():
+    report = build_project_status(None)
+
+    assert report["phase"] == "no_project"
+    assert report["blocking"]

+ 153 - 0
webnovel-writer/scripts/data_modules/tests/test_projection_log.py

@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import sys
+from pathlib import Path
+
+
+def _ensure_scripts_on_path() -> None:
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+
+_ensure_scripts_on_path()
+
+from data_modules.chapter_commit_service import ChapterCommitService  # noqa: E402
+from data_modules.projection_log import (  # noqa: E402
+    append_projection_run,
+    latest_projection_run,
+    projection_log_path,
+    projection_run_pending,
+    projection_run_failed,
+    projection_status_from_run,
+    read_projection_runs,
+)
+
+
+def test_projection_log_appends_and_reads_jsonl(tmp_path):
+    payload = {
+        "meta": {"chapter": 3, "status": "accepted"},
+        "projection_status": {"state": "done", "index": "skipped"},
+    }
+
+    record = append_projection_run(
+        tmp_path,
+        payload,
+        {"state": {"status": "done"}, "index": {"status": "skipped"}},
+    )
+
+    assert projection_log_path(tmp_path).is_file()
+    assert record["status"] == "done"
+    assert read_projection_runs(tmp_path, chapter=3)[0]["run_id"] == record["run_id"]
+    assert latest_projection_run(tmp_path, chapter=3)["commit_hash"] == record["commit_hash"]
+
+
+def test_projection_status_from_run_prefers_writer_statuses(tmp_path):
+    payload = {
+        "meta": {"chapter": 3, "status": "accepted"},
+        "projection_status": {"state": "done", "vector": "done"},
+    }
+
+    record = append_projection_run(
+        tmp_path,
+        payload,
+        {"vector": {"status": "failed:timeout", "error": "timeout"}},
+    )
+
+    assert projection_status_from_run(record) == {"vector": "failed:timeout"}
+    assert projection_run_failed(record) is True
+
+
+def test_projection_log_skips_bad_chapter_when_filtering(tmp_path):
+    path = projection_log_path(tmp_path)
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(
+        "\n".join(
+            [
+                '{"chapter":"bad","writers":{"state":{"status":"done"}}}',
+                '{"chapter":3,"writers":{"state":{"status":"done"}}}',
+            ]
+        ),
+        encoding="utf-8",
+    )
+
+    records = read_projection_runs(tmp_path, chapter=3)
+
+    assert len(records) == 1
+    assert records[0]["chapter"] == 3
+
+
+def test_projection_run_pending_detects_overall_and_writer_pending():
+    assert projection_run_pending({"status": "pending", "writers": {}}) is True
+    assert projection_run_pending({"writers": {"state": {"status": "pending"}}}) is True
+
+
+def test_chapter_commit_service_writes_projection_log(tmp_path):
+    (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+
+    service = ChapterCommitService(tmp_path)
+    payload = service.build_commit(
+        chapter=7,
+        review_result={"blocking_count": 1},
+        fulfillment_result={
+            "planned_nodes": ["进入坊市"],
+            "covered_nodes": ["进入坊市"],
+            "missed_nodes": [],
+            "extra_nodes": [],
+        },
+        disambiguation_result={"pending": []},
+        extraction_result={"state_deltas": [], "entity_deltas": [], "accepted_events": []},
+    )
+
+    service.apply_projections(payload)
+
+    runs = read_projection_runs(tmp_path, chapter=7)
+    assert len(runs) == 1
+    assert runs[0]["commit_status"] == "rejected"
+    assert runs[0]["writers"]["state"]["status"] == "done"
+    assert runs[0]["projection_status"]["state"] == "done"
+
+
+def test_chapter_commit_service_marks_vector_store_zero_as_failed(monkeypatch, tmp_path):
+    (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    monkeypatch.setattr(
+        "data_modules.vector_projection_writer.VectorProjectionWriter._store_chunks",
+        lambda self, chunks: 0,
+    )
+
+    service = ChapterCommitService(tmp_path)
+    payload = service.build_commit(
+        chapter=8,
+        review_result={"blocking_count": 0},
+        fulfillment_result={
+            "planned_nodes": ["突破"],
+            "covered_nodes": ["突破"],
+            "missed_nodes": [],
+            "extra_nodes": [],
+        },
+        disambiguation_result={"pending": []},
+        extraction_result={
+            "state_deltas": [],
+            "entity_deltas": [],
+            "accepted_events": [
+                {
+                    "event_id": "evt-breakthrough",
+                    "event_type": "power_breakthrough",
+                    "chapter": 8,
+                    "subject": "韩立",
+                    "payload": {"field": "realm", "to": "筑基初期"},
+                }
+            ],
+        },
+    )
+
+    projected = service.apply_projections(payload)
+
+    assert projected["projection_status"]["vector"] == "failed:store_failed"
+    latest = latest_projection_run(tmp_path, chapter=8)
+    assert latest is not None
+    assert projection_run_failed(latest) is True
+    assert latest["writers"]["vector"]["status"] == "failed:store_failed"

+ 149 - 0
webnovel-writer/scripts/data_modules/tests/test_projection_writers.py

@@ -2,6 +2,7 @@
 # -*- coding: utf-8 -*-
 
 import json
+import sqlite3
 
 from data_modules.chapter_commit_service import ChapterCommitService
 from data_modules.config import DataModulesConfig
@@ -11,6 +12,7 @@ from data_modules.index_projection_writer import IndexProjectionWriter
 from data_modules.memory_projection_writer import MemoryProjectionWriter
 from data_modules.state_projection_writer import StateProjectionWriter
 from data_modules.summary_projection_writer import SummaryProjectionWriter
+from data_modules.vector_projection_writer import VectorProjectionWriter
 
 
 def test_state_projection_writer_handles_rejected_commit(tmp_path):
@@ -401,6 +403,58 @@ def test_accepted_commit_writes_chapter_index_tables(tmp_path):
     assert changes[0]["field"] == "realm"
 
 
+def test_index_projection_writer_is_idempotent_for_replay(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    chapters_dir = tmp_path / "正文"
+    chapters_dir.mkdir(parents=True, exist_ok=True)
+    (chapters_dir / "第0003章.md").write_text("第三章正文内容", encoding="utf-8")
+
+    service = ChapterCommitService(tmp_path)
+    payload = service.build_commit(
+        chapter=3,
+        review_result={"blocking_count": 0},
+        fulfillment_result={"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
+        disambiguation_result={"pending": []},
+        extraction_result={
+            "summary_text": "本章摘要",
+            "state_deltas": [{"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师"}],
+            "entity_deltas": [
+                {
+                    "entity_id": "xiaoyan",
+                    "canonical_name": "萧炎",
+                    "entity_type": "角色",
+                    "tier": "核心",
+                }
+            ],
+            "entities_appeared": [{"id": "xiaoyan", "mentions": ["萧炎"], "confidence": 0.95}],
+            "scenes": [
+                {
+                    "index": 1,
+                    "start_line": 1,
+                    "end_line": 12,
+                    "location": "山门",
+                    "summary": "萧炎完成突破",
+                    "characters": ["xiaoyan"],
+                }
+            ],
+            "accepted_events": [],
+        },
+    )
+
+    writer = IndexProjectionWriter(tmp_path)
+    writer.apply(payload)
+    writer.apply(payload)
+
+    manager = IndexManager(cfg)
+    assert manager.get_chapter(3)["summary"] == "本章摘要"
+    assert len(manager.get_chapter_appearances(3)) == 1
+    assert len(manager.get_scenes(3)) == 1
+    assert len(manager.get_chapter_state_changes(3)) == 1
+    assert manager.get_entity("xiaoyan")["canonical_name"] == "萧炎"
+
+
 def test_index_projection_writer_records_state_change_from_event(tmp_path):
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()
@@ -448,6 +502,23 @@ def test_summary_projection_writer_writes_summary_markdown(tmp_path):
     assert "剧情摘要" in summary_path.read_text(encoding="utf-8")
 
 
+def test_summary_projection_writer_replay_overwrites_not_appends(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    writer = SummaryProjectionWriter(tmp_path)
+    payload = {
+        "meta": {"status": "accepted", "chapter": 3},
+        "summary_text": "本章主角发现陷阱并决定隐忍。",
+    }
+
+    writer.apply(payload)
+    writer.apply(payload)
+
+    summary_path = tmp_path / ".webnovel" / "summaries" / "ch0003.md"
+    text = summary_path.read_text(encoding="utf-8")
+    assert text.count("本章主角发现陷阱并决定隐忍。") == 1
+
+
 def test_memory_projection_writer_maps_commit_into_scratchpad(tmp_path):
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()
@@ -470,6 +541,84 @@ def test_memory_projection_writer_maps_commit_into_scratchpad(tmp_path):
     assert any(x.subject == "xiaoyan" and x.field == "realm" for x in chars)
 
 
+def test_memory_projection_writer_is_idempotent_for_replay(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    writer = MemoryProjectionWriter(tmp_path)
+    payload = {
+        "meta": {"status": "accepted", "chapter": 3},
+        "state_deltas": [
+            {"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师"}
+        ],
+        "entity_deltas": [],
+        "accepted_events": [],
+    }
+
+    writer.apply(payload)
+    writer.apply(payload)
+
+    store = ScratchpadManager(cfg)
+    chars = [
+        item
+        for item in store.query(category="character_state", subject="xiaoyan", status=None)
+        if item.field == "realm" and item.source_chapter == 3
+    ]
+    assert len(chars) == 1
+
+
+def test_vector_projection_writer_is_idempotent_for_replay(tmp_path, monkeypatch):
+    import data_modules.rag_adapter as rag_module
+
+    class StubClient:
+        async def embed_batch(self, texts, skip_failures=True):
+            return [[1.0, 0.0] for _ in texts]
+
+    monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    writer = VectorProjectionWriter(tmp_path)
+    payload = {
+        "meta": {"status": "accepted", "chapter": 3},
+        "summary_text": "本章主角发现陷阱并决定隐忍。",
+        "entity_deltas": [
+            {
+                "entity_id": "xiaoyan",
+                "canonical_name": "萧炎",
+                "type": "角色",
+                "chapter": 3,
+            }
+        ],
+        "accepted_events": [
+            {
+                "event_id": "evt-power-3",
+                "chapter": 3,
+                "event_type": "power_breakthrough",
+                "subject": "xiaoyan",
+                "payload": {"to": "斗师"},
+            }
+        ],
+        "scenes": [
+            {
+                "index": 1,
+                "location": "山门",
+                "summary": "萧炎完成突破",
+            }
+        ],
+    }
+
+    writer.apply(payload)
+    writer.apply(payload)
+
+    with sqlite3.connect(cfg.vector_db) as conn:
+        vector_count = conn.execute("SELECT COUNT(*) FROM vectors").fetchone()[0]
+        bm25_chunk_count = conn.execute("SELECT COUNT(DISTINCT chunk_id) FROM bm25_index").fetchone()[0]
+        doc_count = conn.execute("SELECT COUNT(*) FROM doc_stats").fetchone()[0]
+
+    assert vector_count == 4
+    assert bm25_chunk_count == 4
+    assert doc_count == 4
+
+
 def test_memory_projection_writer_maps_open_loop_event_into_scratchpad(tmp_path):
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()

+ 101 - 0
webnovel-writer/scripts/data_modules/tests/test_projections_cli.py

@@ -0,0 +1,101 @@
+#!/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[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+
+_ensure_scripts_on_path()
+
+from data_modules.chapter_commit_service import ChapterCommitService  # noqa: E402
+from data_modules.projection_log import read_projection_runs  # noqa: E402
+from data_modules.projections import replay_projections, retry_projection  # noqa: E402
+
+
+def _make_rejected_commit(project_root: Path, chapter: int) -> None:
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    service = ChapterCommitService(project_root)
+    payload = service.build_commit(
+        chapter=chapter,
+        review_result={"blocking_count": 1},
+        fulfillment_result={"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
+        disambiguation_result={"pending": []},
+        extraction_result={"state_deltas": [], "entity_deltas": [], "accepted_events": []},
+    )
+    service.persist_commit(payload)
+
+
+def _make_accepted_commit_with_event(project_root: Path, chapter: int) -> None:
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    service = ChapterCommitService(project_root)
+    payload = service.build_commit(
+        chapter=chapter,
+        review_result={"blocking_count": 0},
+        fulfillment_result={"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
+        disambiguation_result={"pending": []},
+        extraction_result={
+            "state_deltas": [],
+            "entity_deltas": [],
+            "accepted_events": [
+                {
+                    "event_id": "evt-open-loop",
+                    "event_type": "open_loop_created",
+                    "chapter": chapter,
+                    "subject": "韩立",
+                    "payload": {"description": "神秘玉佩为何发热"},
+                }
+            ],
+        },
+    )
+    service.persist_commit(payload)
+
+
+def test_retry_projection_replays_existing_commit(tmp_path):
+    _make_rejected_commit(tmp_path, chapter=3)
+
+    report = retry_projection(tmp_path, chapter=3)
+
+    assert report["ok"] is True
+    assert report["projection_status"]["state"] == "done"
+    state = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
+    assert state["progress"]["chapter_status"]["3"] == "chapter_rejected"
+    assert read_projection_runs(tmp_path, chapter=3)
+
+
+def test_retry_projection_does_not_rewrite_commit_side_effects(tmp_path):
+    _make_accepted_commit_with_event(tmp_path, chapter=3)
+    event_path = tmp_path / ".story-system" / "events" / "chapter_003.events.json"
+    assert not event_path.exists()
+
+    report = retry_projection(tmp_path, chapter=3)
+
+    assert report["ok"] is True
+    assert report["projection_status"]["memory"] in {"done", "skipped"}
+    assert not event_path.exists()
+    assert read_projection_runs(tmp_path, chapter=3)
+
+
+def test_retry_projection_reports_missing_commit(tmp_path):
+    report = retry_projection(tmp_path, chapter=99)
+
+    assert report["ok"] is False
+    assert report["error"] == "missing_commit"
+
+
+def test_replay_projections_runs_range(tmp_path):
+    _make_rejected_commit(tmp_path, chapter=1)
+    _make_rejected_commit(tmp_path, chapter=2)
+
+    report = replay_projections(tmp_path, start_chapter=1, end_chapter=2)
+
+    assert report["ok"] is True
+    assert [item["chapter"] for item in report["results"]] == [1, 2]

+ 1 - 1
webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py

@@ -30,7 +30,7 @@ ALL_PROMPT_FILES = AGENT_FILES + SKILL_FILES
 
 # webnovel.py 注册的子命令(从 add_parser 提取)
 REGISTERED_CLI_SUBCOMMANDS = {
-    "where", "preflight", "use",
+    "where", "preflight", "project-status", "doctor", "write-gate", "projections", "use",
     "index", "state", "rag", "style", "entity", "context", "memory",
     "migrate", "status", "update-state", "backup", "archive",
     "init", "extract-context", "memory-contract", "project-memory", "review-pipeline",

+ 17 - 0
webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py

@@ -127,6 +127,23 @@ def test_rejected_commit_returns_not_applied():
     assert result["applied"] is False
 
 
+def test_store_zero_for_required_chunks_is_error(monkeypatch, tmp_path):
+    writer = VectorProjectionWriter(tmp_path)
+    monkeypatch.setattr(writer, "_store_chunks", lambda chunks: 0)
+
+    result = writer.apply(
+        {
+            "meta": {"status": "accepted", "chapter": 47},
+            "summary_text": "韩立在坊市发现丹方线索。",
+            "accepted_events": [],
+            "entity_deltas": [],
+        }
+    )
+
+    assert result["applied"] is False
+    assert result["reason"] == "error:store_failed"
+
+
 def test_collect_chunks_includes_summary_and_scenes():
     writer = VectorProjectionWriter.__new__(VectorProjectionWriter)
     payload = {

+ 201 - 0
webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py

@@ -23,6 +23,42 @@ def _load_webnovel_module():
     return webnovel_module
 
 
+def _make_cli_init_ready_project(project_root: Path) -> None:
+    dirs = (
+        ".webnovel/backups",
+        ".webnovel/archive",
+        ".webnovel/summaries",
+        "设定集",
+        "大纲",
+        "正文",
+        "审查报告",
+    )
+    for rel in dirs:
+        (project_root / rel).mkdir(parents=True, exist_ok=True)
+
+    (project_root / ".webnovel" / "state.json").write_text(
+        json.dumps(
+            {
+                "project_info": {"title": "测试书", "genre": "玄幻"},
+                "progress": {"current_chapter": 0},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    for rel in (
+        "设定集/世界观.md",
+        "设定集/力量体系.md",
+        "设定集/主角卡.md",
+        "设定集/反派设计.md",
+        "大纲/总纲.md",
+        ".env.example",
+    ):
+        path = project_root / rel
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_text("placeholder\n", encoding="utf-8")
+
+
 def test_init_does_not_resolve_existing_project_root(monkeypatch):
     module = _load_webnovel_module()
 
@@ -325,6 +361,171 @@ def test_preflight_includes_story_runtime_health(monkeypatch, tmp_path, capsys):
     assert '"mainline_ready"' in captured.out
 
 
+def test_project_status_cli_outputs_json_without_reusing_status(monkeypatch, tmp_path, capsys):
+    module = _load_webnovel_module()
+    project_root = tmp_path / "book"
+    _make_cli_init_ready_project(project_root)
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        ["webnovel", "--project-root", str(project_root), "project-status", "--format", "json"],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    captured = capsys.readouterr()
+    report = json.loads(captured.out)
+    assert int(exc.value.code or 0) == 0
+    assert report["schema_version"] == "webnovel-project-status/v1"
+    assert report["project"] == "测试书"
+    assert report["phase"] == "init_ready"
+
+
+def test_doctor_cli_reports_missing_init_file(monkeypatch, tmp_path, capsys):
+    module = _load_webnovel_module()
+    project_root = tmp_path / "book"
+    _make_cli_init_ready_project(project_root)
+    (project_root / "大纲" / "总纲.md").unlink()
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        ["webnovel", "--project-root", str(project_root), "doctor", "--format", "json"],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    captured = capsys.readouterr()
+    report = json.loads(captured.out)
+    assert int(exc.value.code or 0) == 1
+    assert report["schema_version"] == "webnovel-doctor/v1"
+    assert report["ok"] is False
+    assert any(item["id"] == "file.required.大纲/总纲.md" for item in report["checks"])
+
+
+def test_status_command_still_forwards_to_status_reporter(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    project_root = tmp_path / "book"
+    _make_cli_init_ready_project(project_root)
+    called = {}
+
+    def _fake_run_script(script_name, argv):
+        called["script_name"] = script_name
+        called["argv"] = list(argv)
+        return 0
+
+    monkeypatch.setattr(module, "_run_script", _fake_run_script)
+    monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "status", "--focus", "all"])
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    assert int(exc.value.code or 0) == 0
+    assert called["script_name"] == "status_reporter.py"
+
+
+def test_write_gate_cli_runs_prewrite(monkeypatch, tmp_path, capsys):
+    module = _load_webnovel_module()
+    project_root = tmp_path / "book"
+    _make_cli_init_ready_project(project_root)
+    for path, payload in (
+        (project_root / ".story-system" / "MASTER_SETTING.json", {"meta": {"contract_type": "MASTER_SETTING"}}),
+        (project_root / ".story-system" / "volumes" / "volume_001.json", {"meta": {"volume": 1}}),
+        (project_root / ".story-system" / "chapters" / "chapter_001.json", {"chapter_directive": {"must_cover_nodes": []}}),
+        (project_root / ".story-system" / "reviews" / "chapter_001.review.json", {"blocking_rules": []}),
+    ):
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "webnovel",
+            "--project-root",
+            str(project_root),
+            "write-gate",
+            "--chapter",
+            "1",
+            "--stage",
+            "prewrite",
+            "--format",
+            "json",
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    captured = capsys.readouterr()
+    report = json.loads(captured.out)
+    assert int(exc.value.code or 0) == 0
+    assert report["schema_version"] == "webnovel-write-gate/v1"
+    assert report["stage"] == "prewrite"
+    assert report["ok"] is True
+
+
+def test_projections_retry_cli_runs(monkeypatch, tmp_path, capsys):
+    module = _load_webnovel_module()
+    project_root = tmp_path / "book"
+    _make_cli_init_ready_project(project_root)
+    commit_path = project_root / ".story-system" / "commits" / "chapter_001.commit.json"
+    commit_path.parent.mkdir(parents=True, exist_ok=True)
+    commit_path.write_text(
+        json.dumps(
+            {
+                "meta": {"chapter": 1, "status": "rejected"},
+                "review_result": {"blocking_count": 1},
+                "fulfillment_result": {
+                    "planned_nodes": [],
+                    "covered_nodes": [],
+                    "missed_nodes": [],
+                    "extra_nodes": [],
+                },
+                "disambiguation_result": {"pending": []},
+                "extraction_result": {"accepted_events": [], "state_deltas": [], "entity_deltas": []},
+                "projection_status": {
+                    "state": "pending",
+                    "index": "pending",
+                    "summary": "pending",
+                    "memory": "pending",
+                    "vector": "pending",
+                },
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "webnovel",
+            "--project-root",
+            str(project_root),
+            "projections",
+            "retry",
+            "--chapter",
+            "1",
+            "--format",
+            "json",
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    captured = capsys.readouterr()
+    report = json.loads(captured.out)
+    assert int(exc.value.code or 0) == 0
+    assert report["schema_version"] == "webnovel-projections/v1"
+    assert report["projection_status"]["state"] == "done"
+
+
 def test_where_reports_empty_workspace_without_traceback(monkeypatch, tmp_path, capsys):
     module = _load_webnovel_module()
     workspace = tmp_path / "workspace"

+ 196 - 0
webnovel-writer/scripts/data_modules/tests/test_write_gates.py

@@ -0,0 +1,196 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import json
+import sys
+from pathlib import Path
+
+from .test_project_phase import _make_contracts, _make_init_ready, _write_json
+
+
+def _ensure_scripts_on_path() -> None:
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+
+_ensure_scripts_on_path()
+
+from data_modules.write_gates import run_write_gate  # noqa: E402
+from data_modules.projection_log import append_projection_run  # noqa: E402
+
+
+def _write_valid_artifacts(project_root: Path) -> None:
+    _write_json(project_root / ".webnovel" / "tmp" / "review_results.json", {"blocking_count": 0})
+    _write_json(
+        project_root / ".webnovel" / "tmp" / "fulfillment_result.json",
+        {"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
+    )
+    _write_json(project_root / ".webnovel" / "tmp" / "disambiguation_result.json", {"pending": []})
+    _write_json(
+        project_root / ".webnovel" / "tmp" / "extraction_result.json",
+        {"accepted_events": [], "state_deltas": [], "entity_deltas": [], "summary_text": "摘要"},
+    )
+
+
+def test_prewrite_gate_allows_contract_ready_project_with_warning(tmp_path):
+    _make_init_ready(tmp_path)
+    _make_contracts(tmp_path, chapter=1)
+
+    report = run_write_gate(tmp_path, chapter=1, stage="prewrite")
+
+    assert report["ok"] is True
+    assert report["stage"] == "prewrite"
+    assert report["details"]["prewrite_validation"]["blocking"] is False
+
+
+def test_prewrite_gate_wraps_existing_prewrite_validator_blocking(tmp_path):
+    _make_init_ready(tmp_path)
+    _make_contracts(tmp_path, chapter=1)
+    state_path = tmp_path / ".webnovel" / "state.json"
+    state = json.loads(state_path.read_text(encoding="utf-8"))
+    state["disambiguation_pending"] = [{"mention": "宗主"}]
+    state_path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+
+    report = run_write_gate(tmp_path, chapter=1, stage="prewrite")
+
+    assert report["ok"] is False
+    assert any(item["code"] == "prewrite_validator_blocking" for item in report["errors"])
+    assert report["details"]["prewrite_validation"]["disambiguation_domain"]["pending_count"] == 1
+
+
+def test_precommit_gate_reports_missing_artifacts(tmp_path):
+    _make_init_ready(tmp_path)
+    _make_contracts(tmp_path, chapter=1)
+    (tmp_path / "正文" / "第0001章.md").write_text("正文\n", encoding="utf-8")
+
+    report = run_write_gate(tmp_path, chapter=1, stage="precommit")
+
+    assert report["ok"] is False
+    assert any(item["code"] == "artifact.missing_artifact" for item in report["errors"])
+
+
+def test_precommit_gate_accepts_valid_artifacts(tmp_path):
+    _make_init_ready(tmp_path)
+    _make_contracts(tmp_path, chapter=1)
+    (tmp_path / "正文" / "第0001章.md").write_text("正文\n", encoding="utf-8")
+    _write_valid_artifacts(tmp_path)
+
+    report = run_write_gate(tmp_path, chapter=1, stage="precommit")
+
+    assert report["ok"] is True
+    assert report["details"]["artifact_report"]["ok"] is True
+
+
+def test_precommit_gate_blocks_projection_failed_phase(tmp_path):
+    _make_init_ready(tmp_path)
+    _make_contracts(tmp_path, chapter=1)
+    (tmp_path / "正文" / "第0001章.md").write_text("正文\n", encoding="utf-8")
+    _write_valid_artifacts(tmp_path)
+    _write_json(
+        tmp_path / ".story-system" / "commits" / "chapter_001.commit.json",
+        {
+            "meta": {"chapter": 1, "status": "accepted"},
+            "projection_status": {"state": "done", "index": "failed:locked"},
+        },
+    )
+
+    report = run_write_gate(tmp_path, chapter=1, stage="precommit")
+
+    assert report["ok"] is False
+    assert any(item["code"] == "phase_not_ready_for_precommit" for item in report["errors"])
+
+
+def test_postcommit_gate_reports_projection_failure(tmp_path):
+    _make_init_ready(tmp_path)
+    _write_json(
+        tmp_path / ".story-system" / "commits" / "chapter_001.commit.json",
+        {
+            "meta": {"chapter": 1, "status": "accepted"},
+            "review_result": {"blocking_count": 0},
+            "fulfillment_result": {
+                "planned_nodes": [],
+                "covered_nodes": [],
+                "missed_nodes": [],
+                "extra_nodes": [],
+            },
+            "disambiguation_result": {"pending": []},
+            "extraction_result": {
+                "accepted_events": [],
+                "state_deltas": [],
+                "entity_deltas": [],
+                "summary_text": "摘要",
+            },
+            "projection_status": {"state": "done", "index": "failed:locked", "summary": "skipped"},
+        },
+    )
+
+    report = run_write_gate(tmp_path, chapter=1, stage="postcommit")
+
+    assert report["ok"] is False
+    assert any(item["code"] == "commit.projection_failure" for item in report["errors"])
+
+
+def test_postcommit_gate_prefers_projection_log_failure(tmp_path):
+    _make_init_ready(tmp_path)
+    commit_payload = {
+        "meta": {"chapter": 1, "status": "accepted"},
+        "review_result": {"blocking_count": 0},
+        "fulfillment_result": {
+            "planned_nodes": [],
+            "covered_nodes": [],
+            "missed_nodes": [],
+            "extra_nodes": [],
+        },
+        "disambiguation_result": {"pending": []},
+        "extraction_result": {
+            "accepted_events": [],
+            "state_deltas": [],
+            "entity_deltas": [],
+            "summary_text": "摘要",
+        },
+        "projection_status": {"state": "done", "index": "done", "vector": "done"},
+    }
+    commit_path = tmp_path / ".story-system" / "commits" / "chapter_001.commit.json"
+    _write_json(commit_path, commit_payload)
+    append_projection_run(
+        tmp_path,
+        commit_payload,
+        {"vector": {"status": "failed:timeout", "error": "timeout"}},
+        commit_path=commit_path,
+    )
+
+    report = run_write_gate(tmp_path, chapter=1, stage="postcommit")
+
+    assert report["ok"] is False
+    assert any(item["code"] == "projection_failure" for item in report["errors"])
+    assert report["details"]["projection_source"] == "projection_log"
+
+
+def test_postcommit_gate_accepts_done_or_skipped_projection(tmp_path):
+    _make_init_ready(tmp_path)
+    _write_json(
+        tmp_path / ".story-system" / "commits" / "chapter_001.commit.json",
+        {
+            "meta": {"chapter": 1, "status": "accepted"},
+            "review_result": {"blocking_count": 0},
+            "fulfillment_result": {
+                "planned_nodes": [],
+                "covered_nodes": [],
+                "missed_nodes": [],
+                "extra_nodes": [],
+            },
+            "disambiguation_result": {"pending": []},
+            "extraction_result": {
+                "accepted_events": [],
+                "state_deltas": [],
+                "entity_deltas": [],
+                "summary_text": "摘要",
+            },
+            "projection_status": {"state": "done", "index": "skipped", "summary": "skipped", "memory": "skipped"},
+        },
+    )
+
+    report = run_write_gate(tmp_path, chapter=1, stage="postcommit")
+
+    assert report["ok"] is True

+ 8 - 1
webnovel-writer/scripts/data_modules/vector_projection_writer.py

@@ -27,7 +27,14 @@ class VectorProjectionWriter:
 
         try:
             stored = self._store_chunks(chunks)
-            return {"applied": stored > 0, "writer": "vector", "stored": stored}
+            if stored <= 0:
+                return {
+                    "applied": False,
+                    "writer": "vector",
+                    "stored": stored,
+                    "reason": "error:store_failed",
+                }
+            return {"applied": True, "writer": "vector", "stored": stored}
         except Exception as exc:
             logger.warning("vector_projection_failed: %s", exc)
             return {"applied": False, "writer": "vector", "reason": f"error:{exc}"}

+ 86 - 1
webnovel-writer/scripts/data_modules/webnovel.py

@@ -30,12 +30,16 @@ import sys
 from pathlib import Path
 from typing import Optional
 
-from runtime_compat import normalize_windows_path
+from runtime_compat import enable_windows_utf8_stdio, normalize_windows_path
 from project_locator import resolve_project_root, write_current_project_pointer, update_global_registry_current_project
 
 from .story_runtime_health import build_story_runtime_health
 
 
+if sys.platform == "win32":
+    enable_windows_utf8_stdio(skip_in_pytest=True)
+
+
 def _scripts_dir() -> Path:
     # data_modules/webnovel.py -> data_modules -> scripts
     return Path(__file__).resolve().parent.parent
@@ -233,6 +237,58 @@ def cmd_preflight(args: argparse.Namespace) -> int:
     return 0 if report["ok"] else 1
 
 
+def cmd_project_status(args: argparse.Namespace) -> int:
+    from .project_status import build_project_status, format_project_status
+
+    try:
+        root: Path | str | None = _resolve_root(args.project_root)
+    except FileNotFoundError:
+        root = args.project_root or None
+    report = build_project_status(root, chapter=args.chapter)
+    print(format_project_status(report, args.format))
+    return 0
+
+
+def cmd_doctor(args: argparse.Namespace) -> int:
+    from .doctor import build_doctor_report, format_doctor_report
+
+    preflight_report = _build_preflight_report(args.project_root)
+    root: Path | str | None = preflight_report.get("project_root") or args.project_root or None
+    report = build_doctor_report(
+        root,
+        chapter=args.chapter,
+        deep=bool(args.deep),
+        preflight_report=preflight_report,
+    )
+    print(format_doctor_report(report, args.format))
+    return 0 if report.get("ok") else 1
+
+
+def cmd_write_gate(args: argparse.Namespace) -> int:
+    from .write_gates import format_gate_report, run_write_gate
+
+    root = _resolve_root(args.project_root)
+    report = run_write_gate(root, chapter=args.chapter, stage=args.stage)
+    print(format_gate_report(report, args.format))
+    return 0 if report.get("ok") else 1
+
+
+def cmd_projections(args: argparse.Namespace) -> int:
+    from .projections import format_projection_report, replay_projections, retry_projection
+
+    root = _resolve_root(args.project_root)
+    if args.projection_action == "retry":
+        report = retry_projection(root, chapter=args.chapter)
+    else:
+        report = replay_projections(
+            root,
+            start_chapter=args.from_chapter,
+            end_chapter=args.to_chapter,
+        )
+    print(format_projection_report(report, args.format))
+    return 0 if report.get("ok") else 1
+
+
 def cmd_use(args: argparse.Namespace) -> int:
     project_root = normalize_windows_path(args.project_root).expanduser()
     try:
@@ -282,6 +338,35 @@ def main() -> None:
     p_preflight.add_argument("--format", choices=["text", "json"], default="text", help="输出格式")
     p_preflight.set_defaults(func=cmd_preflight)
 
+    p_project_status = sub.add_parser("project-status", help="输出机器可读的项目短状态")
+    p_project_status.add_argument("--chapter", type=int, default=None, help="目标章节号")
+    p_project_status.add_argument("--format", choices=["summary", "json"], default="summary", help="输出格式")
+    p_project_status.set_defaults(func=cmd_project_status)
+
+    p_doctor = sub.add_parser("doctor", help="阶段感知的只读项目体检")
+    p_doctor.add_argument("--chapter", type=int, default=None, help="目标章节号")
+    p_doctor.add_argument("--deep", action="store_true", help="包含 dashboard 等较深检查")
+    p_doctor.add_argument("--format", choices=["text", "json"], default="text", help="输出格式")
+    p_doctor.set_defaults(func=cmd_doctor)
+
+    p_write_gate = sub.add_parser("write-gate", help="写章自然边界校验")
+    p_write_gate.add_argument("--chapter", type=int, required=True, help="目标章节号")
+    p_write_gate.add_argument("--stage", choices=["prewrite", "precommit", "postcommit"], required=True, help="校验阶段")
+    p_write_gate.add_argument("--format", choices=["json", "text"], default="json", help="输出格式")
+    p_write_gate.set_defaults(func=cmd_write_gate)
+
+    p_projections = sub.add_parser("projections", help="从已有 commit 补跑或重放 projection")
+    projections_sub = p_projections.add_subparsers(dest="projection_action", required=True)
+    p_projection_retry = projections_sub.add_parser("retry", help="补跑单章 projection")
+    p_projection_retry.add_argument("--chapter", type=int, required=True, help="目标章节号")
+    p_projection_retry.add_argument("--format", choices=["json", "text"], default="json", help="输出格式")
+    p_projection_retry.set_defaults(func=cmd_projections)
+    p_projection_replay = projections_sub.add_parser("replay", help="按章节范围重放 projection")
+    p_projection_replay.add_argument("--from-chapter", type=int, required=True, help="起始章节号")
+    p_projection_replay.add_argument("--to-chapter", type=int, required=True, help="结束章节号")
+    p_projection_replay.add_argument("--format", choices=["json", "text"], default="json", help="输出格式")
+    p_projection_replay.set_defaults(func=cmd_projections)
+
     p_use = sub.add_parser("use", help="绑定当前工作区使用的书项目(写入指针/registry)")
     p_use.add_argument("project_root", help="书项目根目录(必须包含 .webnovel/state.json)")
     p_use.add_argument("--workspace-root", help="工作区根目录(可选;默认由运行环境推断)")

+ 96 - 0
webnovel-writer/scripts/data_modules/write_gates/__init__.py

@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+
+SCHEMA_VERSION = "webnovel-write-gate/v1"
+STAGES = ("prewrite", "precommit", "postcommit")
+
+
+def issue(
+    code: str,
+    *,
+    message: str,
+    severity: str = "blocker",
+    path: str = "",
+    impact: str = "",
+    repair: str = "",
+    details: Any = None,
+) -> dict[str, Any]:
+    return {
+        "code": code,
+        "severity": severity,
+        "message": message,
+        "path": path,
+        "impact": impact,
+        "repair": repair,
+        "details": details,
+    }
+
+
+def gate_report(
+    *,
+    stage: str,
+    project_root: str | Path,
+    chapter: int,
+    phase: str,
+    errors: list[dict[str, Any]] | None = None,
+    warnings: list[dict[str, Any]] | None = None,
+    details: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    errors = errors or []
+    warnings = warnings or []
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "stage": stage,
+        "project_root": str(project_root),
+        "chapter": chapter,
+        "phase": phase,
+        "ok": not any(item.get("severity") == "blocker" for item in errors),
+        "errors": errors,
+        "warnings": warnings,
+        "details": details or {},
+    }
+
+
+def format_gate_report(report: dict[str, Any], output_format: str = "json") -> 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} write-gate {report.get('stage')}",
+        f"project_root: {report.get('project_root')}",
+        f"chapter: {report.get('chapter')}",
+        f"phase: {report.get('phase')}",
+    ]
+    for item in report.get("errors") 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("impact"):
+            lines.append(f"  impact: {item.get('impact')}")
+        if item.get("repair"):
+            lines.append(f"  repair: {item.get('repair')}")
+    for item in report.get("warnings") or []:
+        lines.append(f"WARNING {item.get('code')}: {item.get('message')}")
+    return "\n".join(lines)
+
+
+def run_write_gate(project_root: str | Path, *, chapter: int, stage: str) -> dict[str, Any]:
+    if stage == "prewrite":
+        from .prewrite import run_prewrite_gate
+
+        return run_prewrite_gate(Path(project_root), chapter)
+    if stage == "precommit":
+        from .precommit import run_precommit_gate
+
+        return run_precommit_gate(Path(project_root), chapter)
+    if stage == "postcommit":
+        from .postcommit import run_postcommit_gate
+
+        return run_postcommit_gate(Path(project_root), chapter)
+    raise ValueError(f"unknown write-gate stage: {stage}")

+ 150 - 0
webnovel-writer/scripts/data_modules/write_gates/postcommit.py

@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+from pathlib import Path
+
+from ..artifact_validator import validate_chapter_commit
+from ..config import DataModulesConfig
+from ..project_phase import resolve_project_phase
+from ..projection_log import latest_projection_run, projection_status_from_run
+from . import gate_report, issue
+
+
+def _commit_path(project_root: Path, chapter: int) -> Path:
+    return project_root / ".story-system" / "commits" / f"chapter_{chapter:03d}.commit.json"
+
+
+def _projection_status_from_runtime(
+    project_root: Path,
+    chapter: int,
+    payload: dict,
+) -> tuple[dict[str, str], str, dict]:
+    try:
+        latest_run = latest_projection_run(project_root, chapter=chapter)
+        logged_status = projection_status_from_run(latest_run)
+    except Exception:
+        latest_run = None
+        logged_status = {}
+    if logged_status:
+        return logged_status, "projection_log", latest_run or {}
+
+    raw_status = payload.get("projection_status") if isinstance(payload, dict) else {}
+    if isinstance(raw_status, dict):
+        return {str(key): str(value) for key, value in raw_status.items()}, "commit", {}
+    return {}, "commit", {}
+
+
+def run_postcommit_gate(project_root: Path, chapter: int) -> dict:
+    snapshot = resolve_project_phase(project_root, chapter=chapter)
+    errors: list[dict] = []
+    warnings: list[dict] = []
+    commit_path = _commit_path(project_root, chapter)
+    commit_report = validate_chapter_commit(commit_path)
+
+    for item in commit_report.get("errors") or []:
+        errors.append(
+            issue(
+                f"commit.{item.get('type')}",
+                message=str(item.get("message") or ""),
+                path=str(item.get("path") or commit_path),
+                impact=str(item.get("impact") or ""),
+                repair=str(item.get("repair") or ""),
+                details=item,
+            )
+        )
+
+    payload = commit_report.get("payload") if isinstance(commit_report.get("payload"), dict) else {}
+    meta = payload.get("meta") if isinstance(payload, dict) else {}
+    status = str((meta or {}).get("status") or "")
+    if commit_path.is_file() and status != "accepted":
+        errors.append(
+            issue(
+                "commit_not_accepted",
+                message=f"chapter commit status is {status or 'missing'}",
+                path=str(commit_path),
+                impact="写章充分性闸门要求 accepted commit 才能进入备份和下一章。",
+                repair="修复 review/fulfillment/disambiguation 阻断项后重新提交。",
+            )
+        )
+
+    projection_status, projection_source, projection_run = _projection_status_from_runtime(
+        project_root,
+        chapter,
+        payload,
+    )
+    if isinstance(projection_status, dict):
+        for writer, writer_status in projection_status.items():
+            status_text = str(writer_status)
+            if projection_source == "projection_log" and status_text.startswith("failed"):
+                errors.append(
+                    issue(
+                        "projection_failure",
+                        message=f"projection {writer} failed: {status_text}",
+                        path=str(commit_path),
+                        impact="最新 projection_log 显示 read-model 投影失败。",
+                        repair="查看 projection_log.jsonl 的 writers 字段,修复后补跑 projection retry/replay。",
+                        details={"source": projection_source, "run": projection_run},
+                    )
+                )
+            elif status_text == "pending":
+                errors.append(
+                    issue(
+                        "projection_pending",
+                        message=f"projection {writer} is still pending",
+                        path=str(commit_path),
+                        impact="read-model 还没有确认写入完成。",
+                        repair="重新运行 chapter-commit 或后续 projection retry/replay。",
+                    )
+                )
+
+    cfg = DataModulesConfig.from_project_root(project_root)
+    if isinstance(projection_status, dict) and projection_status.get("summary") == "done":
+        summary_path = cfg.webnovel_dir / "summaries" / f"ch{chapter:04d}.md"
+        if not summary_path.is_file():
+            errors.append(
+                issue(
+                    "summary_projection_missing",
+                    message="summary projection marked done but file is missing",
+                    path=str(summary_path),
+                    impact="后续上下文无法读取本章摘要。",
+                    repair="补跑 summary projection 或重新执行 chapter-commit。",
+                )
+            )
+    if isinstance(projection_status, dict) and projection_status.get("index") == "done" and not cfg.index_db.is_file():
+        errors.append(
+            issue(
+                "index_projection_missing",
+                message="index projection marked done but index.db is missing",
+                path=str(cfg.index_db),
+                impact="查询、dashboard 和实体关系读模型不可用。",
+                repair="补跑 index projection 或重新执行 chapter-commit。",
+            )
+        )
+    if isinstance(projection_status, dict) and projection_status.get("memory") == "done" and not cfg.scratchpad_file.is_file():
+        warnings.append(
+            issue(
+                "memory_projection_missing",
+                message="memory projection marked done but scratchpad is missing",
+                severity="warning",
+                path=str(cfg.scratchpad_file),
+                impact="长期记忆可能未写入。",
+                repair="检查 memory projection 输出;必要时补跑。",
+            )
+        )
+
+    return gate_report(
+        stage="postcommit",
+        project_root=project_root,
+        chapter=chapter,
+        phase=snapshot.phase,
+        errors=errors,
+        warnings=warnings,
+        details={
+            "phase": snapshot.to_dict(),
+            "commit_path": str(commit_path),
+            "commit_report": commit_report,
+            "projection_source": projection_source,
+            "projection_run": projection_run,
+        },
+    )

+ 122 - 0
webnovel-writer/scripts/data_modules/write_gates/precommit.py

@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+from pathlib import Path
+
+try:
+    from chapter_paths import find_chapter_file
+except ImportError:  # pragma: no cover
+    from scripts.chapter_paths import find_chapter_file
+
+from ..artifact_validator import validate_commit_artifact_files
+from ..project_phase import (
+    COMMIT_ARTIFACT_FILES,
+    PHASE_INIT_READY,
+    PHASE_INIT_SCAFFOLDED,
+    PHASE_NO_PROJECT,
+    PHASE_PLAN_IN_PROGRESS,
+    PHASE_PROJECTION_FAILED,
+    resolve_project_phase,
+)
+from . import gate_report, issue
+
+
+BLOCKED_PRECOMMIT_PHASES = {
+    PHASE_NO_PROJECT,
+    PHASE_INIT_SCAFFOLDED,
+    PHASE_INIT_READY,
+    PHASE_PLAN_IN_PROGRESS,
+    PHASE_PROJECTION_FAILED,
+}
+
+
+def _artifact_paths(project_root: Path) -> dict[str, Path]:
+    return {
+        "review_result": project_root / COMMIT_ARTIFACT_FILES[0],
+        "fulfillment_result": project_root / COMMIT_ARTIFACT_FILES[1],
+        "disambiguation_result": project_root / COMMIT_ARTIFACT_FILES[2],
+        "extraction_result": project_root / COMMIT_ARTIFACT_FILES[3],
+    }
+
+
+def run_precommit_gate(project_root: Path, chapter: int) -> dict:
+    snapshot = resolve_project_phase(project_root, chapter=chapter)
+    errors: list[dict] = []
+    warnings: list[dict] = []
+
+    if snapshot.phase in BLOCKED_PRECOMMIT_PHASES:
+        errors.append(
+            issue(
+                "phase_not_ready_for_precommit",
+                message=f"phase {snapshot.phase} is not ready for precommit",
+                impact="项目骨架、规划合同或上一轮投影状态不完整,继续提交会固化不可靠事实。",
+                repair="先运行 project-status/doctor,并按 next_action 修复当前阶段问题。",
+                details=snapshot.to_dict(),
+            )
+        )
+
+    chapter_file = find_chapter_file(project_root, chapter)
+    if chapter_file is None:
+        errors.append(
+            issue(
+                "chapter_file_missing",
+                message=f"chapter {chapter} file missing",
+                path=str(project_root / "正文"),
+                impact="没有可提交的正文文件。",
+                repair="先完成正文起草并保存到 正文/。",
+            )
+        )
+    elif not chapter_file.read_text(encoding="utf-8").strip():
+        errors.append(
+            issue(
+                "chapter_file_empty",
+                message=f"chapter {chapter} file is empty",
+                path=str(chapter_file),
+                impact="空正文不能提交为章节事实。",
+                repair="补齐正文内容后再提交。",
+            )
+        )
+
+    paths = _artifact_paths(project_root)
+    artifact_report = validate_commit_artifact_files(
+        review_result=paths["review_result"],
+        fulfillment_result=paths["fulfillment_result"],
+        disambiguation_result=paths["disambiguation_result"],
+        extraction_result=paths["extraction_result"],
+    )
+    for item in artifact_report.get("errors") or []:
+        errors.append(
+            issue(
+                f"artifact.{item.get('type')}",
+                message=str(item.get("message") or ""),
+                path=str(item.get("path") or ""),
+                impact=str(item.get("impact") or ""),
+                repair=str(item.get("repair") or ""),
+                details=item,
+            )
+        )
+    for item in artifact_report.get("warnings") or []:
+        warnings.append(
+            issue(
+                f"artifact.{item.get('type')}",
+                message=str(item.get("message") or ""),
+                severity="warning",
+                path=str(item.get("path") or ""),
+                details=item,
+            )
+        )
+
+    return gate_report(
+        stage="precommit",
+        project_root=project_root,
+        chapter=chapter,
+        phase=snapshot.phase,
+        errors=errors,
+        warnings=warnings,
+        details={
+            "phase": snapshot.to_dict(),
+            "chapter_file": str(chapter_file) if chapter_file else "",
+            "artifact_report": artifact_report,
+        },
+    )

+ 114 - 0
webnovel-writer/scripts/data_modules/write_gates/prewrite.py

@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from ..prewrite_validator import PrewriteValidator
+from ..project_phase import (
+    PHASE_CHAPTER_CONTRACT_READY,
+    PHASE_DRAFT_IN_PROGRESS,
+    PHASE_READY_TO_COMMIT,
+    resolve_project_phase,
+)
+from ..story_runtime_sources import load_runtime_sources
+from . import gate_report, issue
+
+
+ALLOWED_PREWRITE_PHASES = {
+    PHASE_CHAPTER_CONTRACT_READY,
+    PHASE_DRAFT_IN_PROGRESS,
+    PHASE_READY_TO_COMMIT,
+}
+
+
+def _plot_structure(chapter_contract: dict[str, Any], review_contract: dict[str, Any]) -> dict[str, Any]:
+    directive = chapter_contract.get("chapter_directive") if isinstance(chapter_contract, dict) else {}
+    if not isinstance(directive, dict):
+        directive = {}
+    return {
+        "mandatory_nodes": list(
+            directive.get("must_cover_nodes")
+            or directive.get("mandatory_nodes")
+            or review_contract.get("must_cover_nodes")
+            or review_contract.get("mandatory_nodes")
+            or []
+        ),
+        "prohibitions": list(
+            directive.get("forbidden_zones")
+            or directive.get("prohibitions")
+            or review_contract.get("blocking_rules")
+            or []
+        ),
+    }
+
+
+def run_prewrite_gate(project_root: Path, chapter: int) -> dict[str, Any]:
+    snapshot = resolve_project_phase(project_root, chapter=chapter)
+    errors: list[dict[str, Any]] = []
+    warnings: list[dict[str, Any]] = []
+
+    if snapshot.phase not in ALLOWED_PREWRITE_PHASES:
+        errors.append(
+            issue(
+                "phase_not_ready_for_prewrite",
+                message=f"phase {snapshot.phase} is not ready for prewrite",
+                impact="写前合同或项目骨架不完整,继续写作容易使用旧上下文或缺失约束。",
+                repair="先运行 project-status/doctor,根据 next_action 补齐 init、plan 或 Story System 合同。",
+                details=snapshot.to_dict(),
+            )
+        )
+
+    runtime = load_runtime_sources(project_root, chapter)
+    contracts = runtime.contracts
+    story_contract = {
+        "master_setting": contracts.get("master") or {},
+        "volume_brief": contracts.get("volume") or {},
+        "chapter_brief": contracts.get("chapter") or {},
+        "review_contract": contracts.get("review") or {},
+    }
+    review_contract = contracts.get("review") or {}
+    plot_structure = _plot_structure(contracts.get("chapter") or {}, review_contract)
+
+    validation = PrewriteValidator(project_root).build(
+        chapter=chapter,
+        review_contract=review_contract,
+        plot_structure=plot_structure,
+        story_contract=story_contract,
+    )
+    if validation.get("blocking"):
+        errors.append(
+            issue(
+                "prewrite_validator_blocking",
+                message="prewrite validator reported blocking issue(s)",
+                impact="当前章节写作输入不可信。",
+                repair="按 blocking_reasons 补齐合同、消歧 pending 或相关占位符。",
+                details=validation,
+            )
+        )
+    elif runtime.fallback_sources:
+        warnings.append(
+            issue(
+                "story_runtime_fallback",
+                message="story runtime has fallback sources",
+                severity="warning",
+                impact="写作上下文可能缺少上一章 accepted commit。",
+                repair="确认这是第一章或补齐 accepted commit 后再写。",
+                details=list(runtime.fallback_sources),
+            )
+        )
+
+    return gate_report(
+        stage="prewrite",
+        project_root=project_root,
+        chapter=chapter,
+        phase=snapshot.phase,
+        errors=errors,
+        warnings=warnings,
+        details={
+            "phase": snapshot.to_dict(),
+            "story_runtime": runtime.to_dict(),
+            "prewrite_validation": validation,
+        },
+    )

+ 270 - 0
webnovel-writer/scripts/run_behavior_evals.py

@@ -0,0 +1,270 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+import tempfile
+from pathlib import Path
+from typing import Any
+
+from runtime_compat import enable_windows_utf8_stdio
+
+
+SCHEMA_VERSION = "webnovel-behavior-eval-report/v1"
+
+
+def _repo_root() -> Path:
+    return Path(__file__).resolve().parents[2]
+
+
+def _plugin_root(root: Path) -> Path:
+    if (root / ".claude-plugin" / "plugin.json").is_file():
+        return root
+    return root / "webnovel-writer"
+
+
+def _read(path: Path) -> str:
+    return path.read_text(encoding="utf-8")
+
+
+def _frontmatter(text: str) -> dict[str, str]:
+    if not text.startswith("---"):
+        return {}
+    end = text.find("\n---", 3)
+    if end < 0:
+        return {}
+    result: dict[str, str] = {}
+    for line in text[3:end].splitlines():
+        if ":" not in line:
+            continue
+        key, _, value = line.partition(":")
+        result[key.strip()] = value.strip()
+    return result
+
+
+def _result(case: dict[str, Any], *, passed: bool, reason: str, evidence: list[str] | None = None) -> dict[str, Any]:
+    return {
+        "id": case.get("id"),
+        "type": case.get("type"),
+        "passed": passed,
+        "reason": reason,
+        "evidence": evidence or [],
+    }
+
+
+def _eval_skill_frontmatter(root: Path, case: dict[str, Any]) -> dict[str, Any]:
+    missing: list[str] = []
+    for skill in sorted((_plugin_root(root) / "skills").glob("*/SKILL.md")):
+        fm = _frontmatter(_read(skill))
+        if not fm.get("name") or not fm.get("description"):
+            missing.append(str(skill.relative_to(root)))
+    return _result(
+        case,
+        passed=not missing,
+        reason="all skills have name and description" if not missing else "skill frontmatter missing",
+        evidence=missing,
+    )
+
+
+def _eval_skill_contract(root: Path, case: dict[str, Any]) -> dict[str, Any]:
+    skill_name = str(case.get("skill") or "").strip()
+    path = _plugin_root(root) / "skills" / skill_name / "SKILL.md"
+    if not path.is_file():
+        return _result(case, passed=False, reason="skill missing", evidence=[str(path)])
+    text = _read(path)
+    missing = [str(item) for item in case.get("required") or [] if str(item) not in text]
+    for group in case.get("required_any") or []:
+        options = [str(item) for item in group]
+        if options and not any(option in text for option in options):
+            missing.append("one of: " + " | ".join(options))
+
+    ordering_errors: list[str] = []
+    for pair in case.get("ordered") or []:
+        if not isinstance(pair, list) or len(pair) != 2:
+            continue
+        left, right = str(pair[0]), str(pair[1])
+        left_pos = text.find(left)
+        right_pos = text.find(right)
+        if left_pos < 0 or right_pos < 0 or left_pos >= right_pos:
+            ordering_errors.append(f"{left} before {right}")
+
+    forbidden = [
+        str(pattern)
+        for pattern in case.get("forbidden_patterns") or []
+        if re.search(str(pattern), text)
+    ]
+    passed = not missing and not ordering_errors and not forbidden
+    return _result(
+        case,
+        passed=passed,
+        reason=f"{skill_name} contract holds" if passed else f"{skill_name} contract drifted",
+        evidence=missing + ordering_errors + forbidden or [str(path.relative_to(root))],
+    )
+
+
+def _eval_write_blocking_gate(root: Path, case: dict[str, Any]) -> dict[str, Any]:
+    path = _plugin_root(root) / "skills" / "webnovel-write" / "SKILL.md"
+    text = _read(path)
+    required = [
+        "blocking=true",
+        "write-gate --chapter {chapter_num} --stage prewrite",
+        "write-gate --chapter {chapter_num} --stage precommit",
+        "write-gate --chapter {chapter_num} --stage postcommit",
+        "chapter-commit",
+    ]
+    missing = [item for item in required if item not in text]
+    precommit_pos = text.find("write-gate --chapter {chapter_num} --stage precommit")
+    commit_pos = text.find("chapter-commit")
+    ordering_ok = precommit_pos >= 0 and commit_pos >= 0 and precommit_pos < commit_pos
+    if not ordering_ok:
+        missing.append("precommit gate must appear before chapter-commit")
+    return _result(
+        case,
+        passed=not missing,
+        reason="write flow keeps blocking and runtime gates" if not missing else "write flow contract missing",
+        evidence=missing or [str(path.relative_to(root))],
+    )
+
+
+def _eval_data_agent_boundary(root: Path, case: dict[str, Any]) -> dict[str, Any]:
+    path = _plugin_root(root) / "agents" / "data-agent.md"
+    text = _read(path)
+    required = [
+        "产出三份 JSON 到 `.webnovel/tmp/`",
+        "不直接写 state/index/summaries/memory",
+        "chapter-commit",
+    ]
+    missing = [item for item in required if item not in text]
+    forbidden_patterns = [
+        r"webnovel\.py[^\n]+state\s+process",
+        r"webnovel\.py[^\n]+memory\s+update",
+        r"webnovel\.py[^\n]+rag\s+index-chapter",
+    ]
+    forbidden = [pattern for pattern in forbidden_patterns if re.search(pattern, text)]
+    return _result(
+        case,
+        passed=not missing and not forbidden,
+        reason="data-agent boundary is artifact-only" if not missing and not forbidden else "data-agent boundary drifted",
+        evidence=missing + forbidden or [str(path.relative_to(root))],
+    )
+
+
+def _eval_commit_projection_runtime(root: Path, case: dict[str, Any]) -> dict[str, Any]:
+    scripts_dir = _plugin_root(root) / "scripts"
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+    from data_modules.chapter_commit_service import ChapterCommitService
+
+    with tempfile.TemporaryDirectory() as tmp:
+        project_root = Path(tmp)
+        (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+        (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+        service = ChapterCommitService(project_root)
+        payload = service.build_commit(
+            chapter=1,
+            review_result={"blocking_count": 1},
+            fulfillment_result={"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
+            disambiguation_result={"pending": []},
+            extraction_result={"accepted_events": [], "state_deltas": [], "entity_deltas": []},
+        )
+        projected = service.apply_projections(payload)
+        state_path = project_root / ".webnovel" / "state.json"
+        state = json.loads(state_path.read_text(encoding="utf-8"))
+    ok = (
+        projected.get("projection_status", {}).get("state") == "done"
+        and state.get("progress", {}).get("chapter_status", {}).get("1") == "chapter_rejected"
+    )
+    return _result(
+        case,
+        passed=ok,
+        reason="chapter commit drives state projection" if ok else "chapter commit projection failed",
+        evidence=[str(projected.get("projection_status"))],
+    )
+
+
+def _eval_dashboard_read_only(root: Path, case: dict[str, Any]) -> dict[str, Any]:
+    path = _plugin_root(root) / "dashboard" / "app.py"
+    text = _read(path)
+    forbidden = re.findall(r"@app\.(post|put|delete|patch)\b", text)
+    get_only = 'allow_methods=["GET"]' in text or 'allow_methods=[\n        "GET"' in text
+    ok = not forbidden and get_only and "strictly read" not in text.lower()
+    # The module's Chinese docstring is the authoritative local signal.
+    ok = ok or (not forbidden and "仅提供 GET 接口" in text)
+    return _result(
+        case,
+        passed=ok,
+        reason="dashboard is GET-only" if ok else "dashboard write endpoint detected",
+        evidence=forbidden or [str(path.relative_to(root))],
+    )
+
+
+EVALUATORS = {
+    "skill_frontmatter": _eval_skill_frontmatter,
+    "skill_contract": _eval_skill_contract,
+    "write_blocking_gate": _eval_write_blocking_gate,
+    "data_agent_boundary": _eval_data_agent_boundary,
+    "commit_projection_runtime": _eval_commit_projection_runtime,
+    "dashboard_read_only": _eval_dashboard_read_only,
+}
+
+
+def load_suite(root: Path, suite: str) -> dict[str, Any]:
+    path = _plugin_root(root) / "evals" / "fixtures" / "behavior" / f"{suite}.json"
+    return json.loads(path.read_text(encoding="utf-8"))
+
+
+def run_behavior_evals(root: str | Path | None = None, *, suite: str = "fast") -> dict[str, Any]:
+    repo_root = Path(root) if root is not None else _repo_root()
+    payload = load_suite(repo_root, suite)
+    results: list[dict[str, Any]] = []
+    for case in payload.get("cases") or []:
+        evaluator = EVALUATORS.get(str(case.get("type") or ""))
+        if evaluator is None:
+            results.append(_result(case, passed=False, reason="unknown eval type"))
+            continue
+        try:
+            results.append(evaluator(repo_root, case))
+        except Exception as exc:
+            results.append(_result(case, passed=False, reason=f"exception: {exc}"))
+    failed = [item for item in results if not item.get("passed")]
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "suite": suite,
+        "ok": not failed,
+        "root": str(repo_root),
+        "total": len(results),
+        "passed": len(results) - len(failed),
+        "failed": len(failed),
+        "results": results,
+    }
+
+
+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} behavior evals {report.get('suite')}: {report.get('passed')}/{report.get('total')} passed"]
+    for item in report.get("results") or []:
+        marker = "PASS" if item.get("passed") else "FAIL"
+        lines.append(f"{marker} {item.get('id')}: {item.get('reason')}")
+    return "\n".join(lines)
+
+
+def main() -> int:
+    if sys.platform == "win32":
+        enable_windows_utf8_stdio()
+    parser = argparse.ArgumentParser(description="Run deterministic webnovel-writer behavior evals")
+    parser.add_argument("--root", default="", help="仓库根目录,默认自动推断")
+    parser.add_argument("--suite", default="fast", choices=["fast"])
+    parser.add_argument("--format", choices=["text", "json"], default="text")
+    args = parser.parse_args()
+    report = run_behavior_evals(args.root or None, suite=args.suite)
+    print(format_report(report, args.format))
+    return 0 if report.get("ok") else 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 95 - 0
webnovel-writer/scripts/tests/test_hooks.py

@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+
+PLUGIN_ROOT = Path(__file__).resolve().parents[1].parent
+HOOKS_JSON = PLUGIN_ROOT / "hooks" / "hooks.json"
+GUARD = PLUGIN_ROOT / "hooks" / "guard_runtime_write.py"
+SESSION_START = PLUGIN_ROOT / "hooks" / "session_start.py"
+
+
+def _run_guard(payload: dict) -> subprocess.CompletedProcess:
+    return subprocess.run(
+        [sys.executable, str(GUARD)],
+        input=json.dumps(payload, ensure_ascii=False),
+        capture_output=True,
+        text=True,
+        encoding="utf-8",
+    )
+
+
+def test_hooks_json_uses_plugin_wrapper_and_plugin_root_paths():
+    payload = json.loads(HOOKS_JSON.read_text(encoding="utf-8"))
+
+    assert "description" in payload
+    assert "hooks" in payload
+    assert "SessionStart" in payload["hooks"]
+    assert "PreToolUse" in payload["hooks"]
+    serialized = json.dumps(payload, ensure_ascii=False)
+    assert "${CLAUDE_PLUGIN_ROOT}" in serialized
+    assert "C:\\Users" not in serialized
+
+
+def test_guard_blocks_direct_commit_file_write():
+    proc = _run_guard(
+        {
+            "tool_name": "Write",
+            "tool_input": {"file_path": r"D:\book\.story-system\commits\chapter_001.commit.json"},
+        }
+    )
+
+    assert proc.returncode == 2
+    assert "permissionDecision" in proc.stderr
+
+
+def test_guard_blocks_direct_state_write():
+    proc = _run_guard(
+        {
+            "tool_name": "Edit",
+            "tool_input": {"file_path": r"D:\book\.webnovel\state.json"},
+        }
+    )
+
+    assert proc.returncode == 2
+
+
+def test_guard_allows_runtime_projection_command():
+    proc = _run_guard(
+        {
+            "tool_name": "Bash",
+            "tool_input": {
+                "command": 'python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" projections retry --chapter 3'
+            },
+        }
+    )
+
+    assert proc.returncode == 0
+
+
+def test_guard_blocks_direct_chapter_commit_script_bypass():
+    proc = _run_guard(
+        {
+            "tool_name": "Bash",
+            "tool_input": {"command": "python scripts/chapter_commit.py --project-root book --chapter 3"},
+        }
+    )
+
+    assert proc.returncode == 2
+
+
+def test_session_start_can_be_disabled(monkeypatch):
+    monkeypatch.setenv("WEBNOVEL_DISABLE_SESSION_STATUS_HOOK", "1")
+    proc = subprocess.run(
+        [sys.executable, str(SESSION_START)],
+        capture_output=True,
+        text=True,
+        encoding="utf-8",
+    )
+
+    assert proc.returncode == 0
+    assert proc.stdout == ""

+ 25 - 0
webnovel-writer/scripts/tests/test_run_behavior_evals.py

@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+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 run_behavior_evals import run_behavior_evals  # noqa: E402
+
+
+def test_run_behavior_evals_fast_suite_passes_for_current_package():
+    root = Path(__file__).resolve().parents[2]
+
+    report = run_behavior_evals(root, suite="fast")
+
+    assert report["ok"] is True
+    assert report["total"] >= 5

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

@@ -0,0 +1,100 @@
+#!/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_plugin_package import validate_package  # 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_minimal_package(root: Path, *, plugin_version: str = "1.2.3", marketplace_version: str = "1.2.3") -> None:
+    _write_json(
+        root / "webnovel-writer" / ".claude-plugin" / "plugin.json",
+        {"name": "webnovel-writer", "version": plugin_version, "description": "desc"},
+    )
+    _write_json(
+        root / ".claude-plugin" / "marketplace.json",
+        {
+            "plugins": [
+                {
+                    "name": "webnovel-writer",
+                    "version": marketplace_version,
+                    "source": "./webnovel-writer",
+                }
+            ]
+        },
+    )
+    (root / "README.md").write_text(
+        "\n".join(
+            [
+                "# Test",
+                "",
+                "| 版本 | 说明 |",
+                "|------|------|",
+                f"| **v{plugin_version} (当前)** | test |",
+                "",
+            ]
+        ),
+        encoding="utf-8",
+    )
+    (root / "webnovel-writer" / "LICENSE").parent.mkdir(parents=True, exist_ok=True)
+    (root / "webnovel-writer" / "LICENSE").write_text("license\n", encoding="utf-8")
+    skill = root / "webnovel-writer" / "skills" / "demo" / "SKILL.md"
+    skill.parent.mkdir(parents=True, exist_ok=True)
+    skill.write_text("---\nname: demo\ndescription: demo\n---\n\n# Demo\n", encoding="utf-8")
+    agent = root / "webnovel-writer" / "agents" / "demo.md"
+    agent.parent.mkdir(parents=True, exist_ok=True)
+    agent.write_text("---\nname: demo\ndescription: demo\ntools: Read\n---\n\n# Demo\n", encoding="utf-8")
+
+
+def test_validate_plugin_package_passes_minimal_package(tmp_path):
+    _write_minimal_package(tmp_path)
+
+    report = validate_package(tmp_path)
+
+    assert report["ok"] is True
+    assert report["error_count"] == 0
+
+
+def test_validate_plugin_package_accepts_plugin_root(tmp_path):
+    _write_minimal_package(tmp_path)
+
+    report = validate_package(tmp_path / "webnovel-writer")
+
+    assert report["ok"] is True
+    assert report["error_count"] == 0
+
+
+def test_validate_plugin_package_detects_version_mismatch(tmp_path):
+    _write_minimal_package(tmp_path, plugin_version="1.2.3", marketplace_version="1.2.4")
+
+    report = validate_package(tmp_path)
+
+    assert report["ok"] is False
+    assert any(item["code"] == "version.marketplace" 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"
+    skill.write_text("---\nname: demo\n---\n\n# Demo\n", encoding="utf-8")
+
+    report = validate_package(tmp_path)
+
+    assert report["ok"] is False
+    assert any(item["code"] == "skill.frontmatter" for item in report["issues"])

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

@@ -0,0 +1,277 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+from typing import Any
+
+import sync_plugin_version
+
+
+SCHEMA_VERSION = "webnovel-plugin-package-validator/v1"
+PLUGIN_NAME = "webnovel-writer"
+KEBAB_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
+SEMVER_RE = sync_plugin_version.VERSION_PATTERN
+LOCAL_ABSOLUTE_RE = re.compile(r"(?i)(?:[a-z]:\\users\\|/users/[^/\s]+/|/home/[^/\s]+/)")
+
+
+def _issue(
+    code: str,
+    *,
+    message: str,
+    severity: str = "error",
+    path: str = "",
+    repair: str = "",
+) -> dict[str, str]:
+    return {
+        "code": code,
+        "severity": severity,
+        "message": message,
+        "path": path,
+        "repair": repair,
+    }
+
+
+def _load_json(path: Path) -> tuple[dict[str, Any], str]:
+    try:
+        payload = json.loads(path.read_text(encoding="utf-8"))
+    except FileNotFoundError:
+        return {}, "missing"
+    except json.JSONDecodeError as exc:
+        return {}, f"invalid_json:{exc}"
+    except OSError as exc:
+        return {}, f"read_error:{exc}"
+    if not isinstance(payload, dict):
+        return {}, "not_object"
+    return payload, ""
+
+
+def _frontmatter(path: Path) -> dict[str, str]:
+    try:
+        text = path.read_text(encoding="utf-8")
+    except OSError:
+        return {}
+    if not text.startswith("---"):
+        return {}
+    end = text.find("\n---", 3)
+    if end < 0:
+        return {}
+    result: dict[str, str] = {}
+    for line in text[3:end].splitlines():
+        if ":" not in line:
+            continue
+        key, _, value = line.partition(":")
+        result[key.strip()] = value.strip()
+    return result
+
+
+def _marketplace_plugin(payload: dict[str, Any]) -> dict[str, Any] | None:
+    plugins = payload.get("plugins")
+    if not isinstance(plugins, list):
+        return None
+    for item in plugins:
+        if isinstance(item, dict) and item.get("name") == PLUGIN_NAME:
+            return item
+    return None
+
+
+def _is_plugin_root(root: Path) -> bool:
+    return (root / ".claude-plugin" / "plugin.json").is_file()
+
+
+def _plugin_root(root: Path) -> Path:
+    return root if _is_plugin_root(root) else root / PLUGIN_NAME
+
+
+def _repo_root(root: Path) -> Path:
+    if _is_plugin_root(root) and (root.parent / ".claude-plugin" / "marketplace.json").is_file():
+        return root.parent
+    return root
+
+
+def _check_manifest(root: Path, issues: list[dict[str, str]]) -> tuple[str, str]:
+    plugin_json = _plugin_root(root) / ".claude-plugin" / "plugin.json"
+    payload, error = _load_json(plugin_json)
+    if error:
+        issues.append(_issue("manifest.plugin_json", message=error, path=str(plugin_json), repair="恢复 .claude-plugin/plugin.json。"))
+        return "", ""
+    name = str(payload.get("name") or "")
+    version = str(payload.get("version") or "")
+    if not KEBAB_RE.fullmatch(name):
+        issues.append(_issue("manifest.name", message=f"invalid plugin name: {name}", path=str(plugin_json), repair="使用 kebab-case 插件名。"))
+    if not SEMVER_RE.fullmatch(version):
+        issues.append(_issue("manifest.version", message=f"invalid semver: {version}", path=str(plugin_json), repair="使用 X.Y.Z 版本号。"))
+    if not str(payload.get("description") or "").strip():
+        issues.append(_issue("manifest.description", message="plugin description missing", path=str(plugin_json), repair="补齐 description。"))
+    return name, version
+
+
+def _check_marketplace(root: Path, plugin_version: str, issues: list[dict[str, str]]) -> None:
+    marketplace = _repo_root(root) / ".claude-plugin" / "marketplace.json"
+    payload, error = _load_json(marketplace)
+    if error:
+        severity = "warning" if _is_plugin_root(root) else "error"
+        issues.append(
+            _issue(
+                "marketplace.json",
+                message=error,
+                severity=severity,
+                path=str(marketplace),
+                repair="在仓库根运行可校验 marketplace;插件根安装包可忽略该项。",
+            )
+        )
+        return
+    plugin = _marketplace_plugin(payload)
+    if plugin is None:
+        issues.append(_issue("marketplace.plugin", message=f"{PLUGIN_NAME} missing from marketplace", path=str(marketplace), repair="在 plugins[] 中加入 webnovel-writer。"))
+        return
+    if plugin.get("source") != "./webnovel-writer":
+        issues.append(_issue("marketplace.source", message=f"unexpected source: {plugin.get('source')}", path=str(marketplace), repair="source 应为 ./webnovel-writer。"))
+    marketplace_version = str(plugin.get("version") or "")
+    if plugin_version and marketplace_version != plugin_version:
+        issues.append(
+            _issue(
+                "version.marketplace",
+                message=f"plugin.json={plugin_version}, marketplace.json={marketplace_version}",
+                path=str(marketplace),
+                repair="运行 sync_plugin_version.py --version X.Y.Z --release-notes ...。",
+            )
+        )
+
+
+def _check_readme_version(root: Path, plugin_version: str, issues: list[dict[str, str]]) -> None:
+    if _is_plugin_root(root):
+        candidates = [_repo_root(root) / "README.md", root / "README.md"]
+    else:
+        candidates = [root / "README.md", _plugin_root(root) / "README.md"]
+    readme = next((candidate for candidate in candidates if candidate.is_file()), candidates[0])
+    try:
+        content = readme.read_text(encoding="utf-8")
+        readme_version = sync_plugin_version.get_readme_current_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
+    if plugin_version and readme_version != plugin_version:
+        issues.append(
+            _issue(
+                "version.readme",
+                message=f"plugin.json={plugin_version}, README.md={readme_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:
+    plugin_root = _plugin_root(root)
+    for skill in sorted((plugin_root / "skills").glob("*/SKILL.md")):
+        fm = _frontmatter(skill)
+        for field in ("name", "description"):
+            if not fm.get(field):
+                issues.append(_issue("skill.frontmatter", message=f"skill missing {field}", path=str(skill), repair="按 plugin-dev skill-development 补齐 frontmatter。"))
+    for agent in sorted((plugin_root / "agents").glob("*.md")):
+        fm = _frontmatter(agent)
+        for field in ("name", "description", "tools"):
+            if not fm.get(field):
+                issues.append(_issue("agent.frontmatter", message=f"agent missing {field}", path=str(agent), repair="按 plugin-dev agent-development 补齐 frontmatter。"))
+
+
+def _check_optional_assets(root: Path, issues: list[dict[str, str]]) -> None:
+    plugin_root = _plugin_root(root)
+    if not (plugin_root / "LICENSE").is_file():
+        issues.append(_issue("license", message="LICENSE missing", severity="error", path=str(plugin_root / "LICENSE"), repair="恢复插件 LICENSE。"))
+    dashboard_dist = plugin_root / "dashboard" / "frontend" / "dist"
+    if not dashboard_dist.is_dir():
+        issues.append(_issue("dashboard.dist", message="dashboard frontend dist missing", severity="warning", path=str(dashboard_dist), repair="发布前运行 dashboard 前端 build 并包含 dist。"))
+    hooks_json = plugin_root / "hooks" / "hooks.json"
+    if hooks_json.exists():
+        payload, error = _load_json(hooks_json)
+        if error:
+            issues.append(_issue("hooks.schema", message=error, path=str(hooks_json), repair="修复 hooks/hooks.json。"))
+        elif "description" not in payload or "hooks" not in payload:
+            issues.append(_issue("hooks.wrapper", message="hooks.json should use plugin-dev wrapper format", path=str(hooks_json), repair="外层包含 description 与 hooks。"))
+
+
+def _check_portability(root: Path, issues: list[dict[str, str]]) -> None:
+    plugin_root = _plugin_root(root)
+    targets = list((plugin_root / "skills").glob("*/SKILL.md"))
+    targets.extend((plugin_root / "agents").glob("*.md"))
+    targets.extend((plugin_root / ".claude-plugin").glob("*.json"))
+    hooks_root = plugin_root / "hooks"
+    if hooks_root.is_dir():
+        targets.extend(path for path in hooks_root.rglob("*") if path.suffix in {".json", ".py", ".sh", ".md"})
+    for path in targets:
+        try:
+            text = path.read_text(encoding="utf-8")
+        except OSError:
+            continue
+        if LOCAL_ABSOLUTE_RE.search(text):
+            issues.append(
+                _issue(
+                    "portability.local_absolute_path",
+                    message="local absolute path found in plugin component",
+                    severity="warning",
+                    path=str(path),
+                    repair="插件组件内使用 ${CLAUDE_PLUGIN_ROOT} 或相对路径。",
+                )
+            )
+
+
+def validate_package(root: str | Path | None = None, *, strict: bool = False) -> dict[str, Any]:
+    repo_root = Path(root) if root is not None else Path(__file__).resolve().parent.parent.parent
+    issues: list[dict[str, str]] = []
+    _, plugin_version = _check_manifest(repo_root, issues)
+    _check_marketplace(repo_root, plugin_version, issues)
+    _check_readme_version(repo_root, plugin_version, issues)
+    _check_frontmatter(repo_root, issues)
+    _check_optional_assets(repo_root, issues)
+    _check_portability(repo_root, issues)
+    blocking = [
+        item for item in issues if item["severity"] == "error" or (strict and item["severity"] == "warning")
+    ]
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "ok": not blocking,
+        "strict": strict,
+        "root": str(repo_root),
+        "error_count": sum(1 for item in issues if item["severity"] == "error"),
+        "warning_count": sum(1 for item in issues if item["severity"] == "warning"),
+        "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} plugin package",
+        f"errors: {report.get('error_count')} warnings: {report.get('warning_count')}",
+    ]
+    for item in report.get("issues") or []:
+        lines.append(f"{item.get('severity', '').upper()} {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 webnovel-writer plugin package metadata and components")
+    parser.add_argument("--root", default="", help="仓库根目录,默认自动推断")
+    parser.add_argument("--strict", action="store_true", help="warning 也视为失败")
+    parser.add_argument("--format", choices=["text", "json"], default="text")
+    args = parser.parse_args()
+
+    report = validate_package(args.root or None, strict=args.strict)
+    print(format_report(report, args.format))
+    return 0 if report.get("ok") else 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 70 - 0
webnovel-writer/skills/webnovel-doctor/SKILL.md

@@ -0,0 +1,70 @@
+---
+name: webnovel-doctor
+description: This skill should be used when the user asks to "/webnovel-doctor", "检查项目环境", "体检网文项目", "排查 RAG 配置", "检查缺失文件", "项目状态不对", or needs a read-only diagnosis of webnovel-writer project files, databases, dependencies, and runtime configuration.
+version: 0.1.0
+allowed-tools: Read Bash
+argument-hint: "[--chapter N] [--deep]"
+---
+
+# Webnovel Doctor
+
+## 目标
+
+运行只读项目体检,确认当前书项目在所处阶段应该具备的目录、文件、JSON、SQLite、RAG 配置、Python 依赖和 Dashboard 构建产物是否完整。
+
+## 原则
+
+1. 只读诊断,不写入项目文件,不自动修复,不安装依赖,不启动 Dashboard。
+2. 先运行 `project-status` 获取短状态,再运行 `doctor` 获取详细检查。
+3. 使用 `python -X utf8`,避免 Windows 中文路径和中文文件名编码问题。
+4. 保留旧 `status` 命令语义;需要短状态时使用 `project-status`,需要宏观创作健康报告时才使用 `status`。
+5. 根据 doctor 输出说明影响和修复建议;缺失项不要自行猜测为终态要求,阶段由 runtime 推导。
+
+## 执行
+
+准备路径:
+
+```bash
+export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
+export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT:?}/scripts"
+```
+
+短状态:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" project-status --format summary
+```
+
+标准体检:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" doctor --format text
+```
+
+指定章节:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" doctor --chapter {chapter_num} --format text
+```
+
+深度体检:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" doctor --deep --format text
+```
+
+## 输出方式
+
+汇报时包含:
+
+- 当前 `phase` 和 `target_chapter`。
+- 是否有 blocker。
+- 缺失或异常文件的路径。
+- RAG / Python / Dashboard 配置是否缺失。
+- 每个问题的影响和建议修复动作。
+
+避免输出:
+
+- 不执行真实修复。
+- 不展示或要求用户粘贴 API key。
+- 不把 init 刚结束的项目按已写多章项目检查。

+ 18 - 1
webnovel-writer/skills/webnovel-write/SKILL.md

@@ -63,6 +63,9 @@ GENRE="$(python -X utf8 -c "import json,sys; s=json.load(open('${PROJECT_ROOT}/.
 
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" \
   story-system "${CHAPTER_GOAL}" --genre "${GENRE}" --chapter {chapter_num} --persist --emit-runtime-contracts --format both
+
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" \
+  write-gate --chapter {chapter_num} --stage prewrite --format json
 ```
 
 必备文件:`MASTER_SETTING.json`(调性/禁忌)、`volume_{NNN}.json`(卷级节奏)、`chapter_{NNN}.review.json`(必须节点/禁区)。缺失则阻断。
@@ -141,6 +144,9 @@ Data Agent 只提取事实+生成 artifacts,不直接写 state/index/summaries
 #### 5.2 CHAPTER_COMMIT
 
 ```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" \
+  write-gate --chapter {chapter_num} --stage precommit --format json
+
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" chapter-commit \
   --chapter {chapter_num} \
   --review-result "${PROJECT_ROOT}/.webnovel/tmp/review_results.json" \
@@ -157,9 +163,19 @@ projection_status 五项(state/index/summary/memory/vector)全部 done 或 s
 
 chapter_status 由 projection writer 自动推进:accepted→committed,rejected→rejected。
 
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" \
+  write-gate --chapter {chapter_num} --stage postcommit --format json
+```
+
 #### 5.4 失败隔离
 
-commit 未生成→重跑 5.2。projection 失败→只补跑失败项。不回退 Step 1-4。
+commit 未生成→重跑 5.2。projection 失败→只补跑 projection,不回退 Step 1-4。
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" \
+  projections retry --chapter {chapter_num} --format json
+```
 
 ### Step 6:Git 备份
 
@@ -179,6 +195,7 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" bac
 4. anti_ai_force_check=pass(`--minimal` 除外)
 5. accepted CHAPTER_COMMIT,projection 五项 done/skipped
 6. chapter_status=committed(projection 自动推进)
+7. `write-gate` 的 prewrite / precommit / postcommit 均通过
 
 ## 失败恢复