Procházet zdrojové kódy

docs: add revised story system phase1-4 plans

lingfengQAQ před 2 měsíci
rodič
revize
617ee014de

+ 1324 - 0
docs/superpowers/plans/2026-04-12-story-system-phase1-contract-seed.md

@@ -0,0 +1,1324 @@
+# Story System Phase 1 Contract Seed Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 在不破坏现有 `reference_search.py`、`context_manager.py` 与写作主流程的前提下,落地 Story System Phase 1 合同种子层:`题材与调性推理.csv`、最小 `MASTER_SETTING` / `CHAPTER_BRIEF` / `anti_patterns` 持久化,以及 `context_manager` 的合同读取入口。
+
+**Architecture:** 采用“数据层 -> 合同聚合器 -> `.story-system/` 持久化 -> runtime 注入”的四段式。Phase 1 只建立最小合同真源,不引入 `VOLUME_BRIEF`、`REVIEW_CONTRACT`、`CHAPTER_COMMIT`,并继续允许 `genre-profiles.md` 作为回退源存在。
+
+**Tech Stack:** Python 3.13, argparse, pytest, CSV(UTF-8 with BOM), Markdown + JSON 合同文件, unified CLI `webnovel.py`
+
+**Spec:** `docs/superpowers/specs/2026-04-12-story-system-evolution-spec.md`
+
+**Companion Spec:** `docs/superpowers/specs/2026-04-12-story-system-pro-max-retrofit-spec.md`
+
+---
+
+## Scope Split
+
+这份 plan **只覆盖 evolution spec 的 Phase 1**,原因如下:
+
+- `Phase 2` 以后会引入 `VOLUME_BRIEF`、`REVIEW_CONTRACT`、大纲履约 diff、review blocking rules。
+- `Phase 3` 会新增 `CHAPTER_COMMIT` 与四类 projection writers。
+- `Phase 4` 会新增 canonical event log。
+- `Phase 5` 才能安全降级旧链路。
+
+把这些内容塞进一份实现计划会导致:
+
+- 文件责任边界失真
+- TDD 粒度失控
+- 文档与代码修改无法形成可验证的阶段产物
+
+因此本计划的退出标准固定为:
+
+1. `PROJECT_ROOT/.story-system/` 能生成最小 `MASTER_SETTING`、`chapter_XXX`、`anti_patterns`
+2. `context_manager` 能读取并注入 `story_contract` section
+3. 旧 `genre_profile` 仍可作为回退层保留
+4. 文档已明确 Phase 1 的路径语义、schema 与使用方式
+
+后续应另写三份计划:
+
+- `Phase 2 Contract-First Runtime`
+- `Phase 3 Chapter Commit Chain`
+- `Phase 4 Event Log + Override Ledger`
+
+文档边界也在本阶段定死:
+
+- `README.md` 只新增 `Story System` 一级段落与基础目录说明
+- 后续 Phase 2/3/4 只能在既有段落下追加,不重写整段结构
+
+---
+
+## File Structure
+
+### 要创建的文件
+
+- `webnovel-writer/references/csv/题材与调性推理.csv`
+- `webnovel-writer/scripts/data_modules/story_contracts.py`
+- `webnovel-writer/scripts/data_modules/story_system_engine.py`
+- `webnovel-writer/scripts/data_modules/tests/test_story_contracts.py`
+- `webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py`
+- `webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py`
+- `webnovel-writer/scripts/story_system.py`
+- `docs/architecture/story-system-phase1.md`
+
+### 要修改的文件
+
+- `webnovel-writer/references/csv/README.md`
+- `webnovel-writer/references/csv/桥段套路.csv`
+- `webnovel-writer/scripts/data_modules/config.py`
+- `webnovel-writer/scripts/data_modules/context_manager.py`
+- `webnovel-writer/scripts/data_modules/tests/test_context_manager.py`
+- `webnovel-writer/scripts/extract_chapter_context.py`
+- `webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py`
+- `webnovel-writer/scripts/data_modules/webnovel.py`
+- `webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py`
+- `README.md`
+- `docs/architecture/overview.md`
+- `docs/guides/commands.md`
+- `docs/superpowers/README.md`
+
+### 文件职责
+
+- `story_contracts.py`:合同路径、merge 规则、JSON/Markdown 持久化、marker 安全更新
+- `story_system_engine.py`:题材路由、多表检索编排、anti-pattern 聚合、最小合同字典构造
+- `story_system.py`:CLI 入口,负责 `query -> build -> render -> persist`
+- `context_manager.py`:读取 `MASTER_SETTING` / `chapter_XXX` / `anti_patterns` 并注入 `story_contract` section
+- `extract_chapter_context.py`:把 `story_contract` 纳入可视化文本/JSON 提取结果
+- `docs/architecture/story-system-phase1.md`:Phase 1 合同 schema、目录结构、覆盖规则、迁移说明
+
+---
+
+## Task 1: 建立合同路径层与最小 merge 规则
+
+**Files:**
+- Create: `webnovel-writer/scripts/data_modules/story_contracts.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_story_contracts.py`
+- Modify: `webnovel-writer/scripts/data_modules/config.py`
+
+- [ ] **Step 1: 先写 `story_contracts` 的失败测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_story_contracts.py
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.config import DataModulesConfig
+from data_modules.story_contracts import StoryContractPaths, merge_contract_layers, merge_anti_patterns
+
+
+def test_story_contract_paths_live_under_project_root(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    paths = StoryContractPaths.from_project_root(cfg.project_root)
+
+    assert paths.root == tmp_path / ".story-system"
+    assert paths.master_json == tmp_path / ".story-system" / "MASTER_SETTING.json"
+    assert paths.anti_patterns_json == tmp_path / ".story-system" / "anti_patterns.json"
+    assert paths.chapter_json(1) == tmp_path / ".story-system" / "chapters" / "chapter_001.json"
+
+
+def test_merge_contract_layers_respects_lock_categories():
+    master = {
+        "locked": {
+            "core_tone": "冷硬升级",
+            "golden_finger_limit": "每天只能触发一次",
+        },
+        "append_only": {
+            "anti_patterns": ["配角连续抢戏超过 300 字"],
+        },
+        "override_allowed": {
+            "scene_focus": "拍卖会打脸",
+        },
+    }
+    chapter = {
+        "locked": {
+            "core_tone": "轻喜日常",
+        },
+        "append_only": {
+            "anti_patterns": ["本章禁止解释性旁白"],
+        },
+        "override_allowed": {
+            "scene_focus": "退婚当场反杀",
+        },
+    }
+
+    merged = merge_contract_layers(master, chapter)
+
+    assert merged["locked"]["core_tone"] == "冷硬升级"
+    assert merged["locked"]["golden_finger_limit"] == "每天只能触发一次"
+    assert merged["append_only"]["anti_patterns"] == [
+        "配角连续抢戏超过 300 字",
+        "本章禁止解释性旁白",
+    ]
+    assert merged["override_allowed"]["scene_focus"] == "退婚当场反杀"
+
+
+def test_merge_anti_patterns_deduplicates_by_text():
+    rows = merge_anti_patterns(
+        [{"text": "打脸节奏不能缺补刀", "source_table": "题材与调性推理", "source_id": "GR-001"}],
+        [{"text": "打脸节奏不能缺补刀", "source_table": "爽点与节奏", "source_id": "PA-002"}],
+    )
+
+    assert [item["text"] for item in rows] == ["打脸节奏不能缺补刀"]
+    assert rows[0]["source_table"] == "题材与调性推理"
+```
+
+- [ ] **Step 2: 运行测试,确认是正确的红灯**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_contracts.py -q --no-cov`
+
+Expected: `ModuleNotFoundError: No module named 'data_modules.story_contracts'`
+
+- [ ] **Step 3: 在 `config.py` 增加 `.story-system` 路径属性**
+
+```python
+# webnovel-writer/scripts/data_modules/config.py
+@property
+def story_system_dir(self) -> Path:
+    return self.project_root / ".story-system"
+
+@property
+def story_system_chapters_dir(self) -> Path:
+    return self.story_system_dir / "chapters"
+
+@property
+def story_system_master_json(self) -> Path:
+    return self.story_system_dir / "MASTER_SETTING.json"
+
+@property
+def story_system_anti_patterns_json(self) -> Path:
+    return self.story_system_dir / "anti_patterns.json"
+```
+
+- [ ] **Step 4: 实现 `StoryContractPaths`、merge 规则与 marker 更新工具**
+
+```python
+# webnovel-writer/scripts/data_modules/story_contracts.py
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict, Iterable, List
+
+
+MARKER_BEGIN = "<!-- STORY-SYSTEM:BEGIN -->"
+MARKER_END = "<!-- STORY-SYSTEM:END -->"
+
+
+@dataclass(frozen=True)
+class StoryContractPaths:
+    project_root: Path
+
+    @classmethod
+    def from_project_root(cls, project_root: str | Path) -> "StoryContractPaths":
+        return cls(Path(project_root).expanduser().resolve())
+
+    @property
+    def root(self) -> Path:
+        return self.project_root / ".story-system"
+
+    @property
+    def chapters_dir(self) -> Path:
+        return self.root / "chapters"
+
+    @property
+    def master_json(self) -> Path:
+        return self.root / "MASTER_SETTING.json"
+
+    @property
+    def anti_patterns_json(self) -> Path:
+        return self.root / "anti_patterns.json"
+
+    def chapter_json(self, chapter: int) -> Path:
+        return self.chapters_dir / f"chapter_{chapter:03d}.json"
+
+
+def merge_contract_layers(master: Dict[str, Any], chapter: Dict[str, Any] | None) -> Dict[str, Any]:
+    chapter = chapter or {}
+    return {
+        "locked": dict(master.get("locked") or {}),
+        "append_only": _merge_append_only(master.get("append_only") or {}, chapter.get("append_only") or {}),
+        "override_allowed": {
+            **(master.get("override_allowed") or {}),
+            **(chapter.get("override_allowed") or {}),
+        },
+    }
+
+
+def merge_anti_patterns(*groups: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    seen: set[str] = set()
+    merged: List[Dict[str, Any]] = []
+    for group in groups:
+        for row in group:
+            text = str(row.get("text") or "").strip()
+            if not text or text in seen:
+                continue
+            seen.add(text)
+            merged.append(dict(row))
+    return merged
+
+
+def read_json_if_exists(path: Path) -> Any | None:
+    if not path.is_file():
+        return None
+    try:
+        return json.loads(path.read_text(encoding="utf-8"))
+    except json.JSONDecodeError as exc:
+        raise ValueError(f"Bad JSON in {path}") from exc
+```
+
+同时补一个统一读取约定,供后续 Phase 2-4 复用:
+
+- `read_json_if_exists(path) -> dict | list | None`
+- 文件不存在时返回 `None`
+- JSON 格式错误时抛带路径的 `ValueError`
+- 本阶段先在 `story_contracts.py` 落这个 helper,后续 projection / runtime builder 不再各写一套吞错逻辑
+
+- [ ] **Step 5: 回跑测试,确认基础层转绿**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_contracts.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add webnovel-writer/scripts/data_modules/config.py \
+        webnovel-writer/scripts/data_modules/story_contracts.py \
+        webnovel-writer/scripts/data_modules/tests/test_story_contracts.py
+git commit -m "feat: add story contract path helpers and merge rules"
+```
+
+---
+
+## Task 2: 落地题材路由表与合同聚合器
+
+**Files:**
+- Create: `webnovel-writer/references/csv/题材与调性推理.csv`
+- Create: `webnovel-writer/scripts/data_modules/story_system_engine.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py`
+- Modify: `webnovel-writer/references/csv/README.md`
+- Modify: `webnovel-writer/references/csv/桥段套路.csv`
+
+- [ ] **Step 1: 先写 `story_system_engine` 的失败测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import csv
+
+from data_modules.story_system_engine import StorySystemEngine
+
+
+def _write_csv(path, headers, rows):
+    with open(path, "w", encoding="utf-8-sig", newline="") as f:
+        writer = csv.DictWriter(f, fieldnames=headers)
+        writer.writeheader()
+        writer.writerows(rows)
+
+
+def test_story_system_routes_explicit_genre_and_collects_anti_patterns(tmp_path):
+    csv_dir = tmp_path / "csv"
+    csv_dir.mkdir()
+
+    _write_csv(
+        csv_dir / "题材与调性推理.csv",
+        [
+            "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
+            "大模型指令", "核心摘要", "详细展开", "题材/流派", "题材别名", "核心调性",
+            "节奏策略", "强制禁忌/毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
+        ],
+        [
+            {
+                "编号": "GR-001",
+                "适用技能": "write|plan",
+                "分类": "题材路由",
+                "层级": "知识补充",
+                "关键词": "玄幻退婚流|退婚打脸",
+                "意图与同义词": "退婚流|废材逆袭",
+                "适用题材": "玄幻",
+                "大模型指令": "先给压抑,再给爆发兑现。",
+                "核心摘要": "玄幻退婚流需要耻辱起手和强兑现。",
+                "详细展开": "",
+                "题材/流派": "玄幻退婚流",
+                "题材别名": "退婚流|废材逆袭",
+                "核心调性": "压抑蓄势后爆裂反击",
+                "节奏策略": "前压后爆,三章内必须首个反打",
+                "强制禁忌/毒点": "打脸节奏不能缺最后一拍补刀|配角不能压过主角兑现",
+                "推荐基础检索表": "命名规则|人设与关系|金手指与设定",
+                "推荐动态检索表": "桥段套路|爽点与节奏|场景写法",
+                "默认查询词": "退婚|打脸|废材逆袭",
+            }
+        ],
+    )
+
+    _write_csv(
+        csv_dir / "桥段套路.csv",
+        ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "桥段名称", "忌讳写法"],
+        [
+            {
+                "编号": "TR-001",
+                "适用技能": "write",
+                "分类": "桥段",
+                "层级": "知识补充",
+                "关键词": "退婚|打脸",
+                "适用题材": "玄幻",
+                "核心摘要": "退婚现场要给足羞辱和反击空间",
+                "桥段名称": "退婚反击",
+                "忌讳写法": "主角还没反打就被配角替他出手",
+            }
+        ],
+    )
+
+    _write_csv(
+        csv_dir / "爽点与节奏.csv",
+        ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "常见崩盘误区", "节奏类型"],
+        [
+            {
+                "编号": "PA-001",
+                "适用技能": "write",
+                "分类": "节奏",
+                "层级": "知识补充",
+                "关键词": "打脸|兑现",
+                "适用题材": "玄幻",
+                "核心摘要": "兑现必须补刀",
+                "常见崩盘误区": "打脸收尾太软,没有读者情绪补刀",
+                "节奏类型": "爆发期",
+            }
+        ],
+    )
+
+    engine = StorySystemEngine(csv_dir=csv_dir)
+    contract = engine.build(query="玄幻退婚流", genre=None, chapter=None)
+
+    assert contract["master_setting"]["route"]["primary_genre"] == "玄幻退婚流"
+    assert contract["master_setting"]["master_constraints"]["core_tone"] == "压抑蓄势后爆裂反击"
+    assert "命名规则" in contract["master_setting"]["route"]["recommended_base_tables"]
+    assert {
+        item["text"] for item in contract["anti_patterns"]
+    } >= {
+        "打脸节奏不能缺最后一拍补刀",
+        "主角还没反打就被配角替他出手",
+        "打脸收尾太软,没有读者情绪补刀",
+    }
+
+
+def test_story_system_falls_back_to_explicit_genre(tmp_path):
+    csv_dir = tmp_path / "csv"
+    csv_dir.mkdir()
+
+    _write_csv(
+        csv_dir / "题材与调性推理.csv",
+        [
+            "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
+            "大模型指令", "核心摘要", "详细展开", "题材/流派", "题材别名", "核心调性",
+            "节奏策略", "强制禁忌/毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
+        ],
+        [],
+    )
+
+    engine = StorySystemEngine(csv_dir=csv_dir)
+    contract = engine.build(query="压抑一点,后面爆", genre="现言", chapter=None)
+
+    assert contract["master_setting"]["route"]["primary_genre"] == "现言"
+    assert contract["master_setting"]["route"]["route_source"] == "explicit_genre_fallback"
+    assert contract["master_setting"]["route"]["recommended_dynamic_tables"] == ["桥段套路", "爽点与节奏", "场景写法"]
+```
+
+- [ ] **Step 2: 跑红灯,确认缺的是聚合器而不是测试本身**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py -q --no-cov`
+
+Expected: `ModuleNotFoundError: No module named 'data_modules.story_system_engine'`
+
+- [ ] **Step 3: 实现 `StorySystemEngine` 与 anti-pattern 归一化映射**
+
+```python
+# webnovel-writer/scripts/data_modules/story_system_engine.py
+from __future__ import annotations
+
+import csv
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from reference_search import search as search_reference
+
+from .story_contracts import merge_anti_patterns
+
+
+ANTI_PATTERN_SOURCE_FIELDS = {
+    "场景写法": ["反面写法"],
+    "写作技法": ["常见误区"],
+    "爽点与节奏": ["常见崩盘误区"],
+    "人设与关系": ["忌讳写法"],
+    "桥段套路": ["忌讳写法"],
+    "题材与调性推理": ["强制禁忌/毒点"],
+}
+
+
+class StorySystemEngine:
+    def __init__(self, csv_dir: str | Path):
+        self.csv_dir = Path(csv_dir)
+
+    def build(self, query: str, genre: Optional[str], chapter: Optional[int]) -> Dict[str, Any]:
+        route = self._route(query=query, genre=genre)
+        base_context = self._collect_tables(query, route["recommended_base_tables"], genre=route["genre_filter"], top_k=1)
+        dynamic_context = self._collect_tables(query, route["recommended_dynamic_tables"], genre=route["genre_filter"], top_k=2)
+        source_trace = route["source_trace"] + self._build_source_trace(base_context, dynamic_context)
+        anti_patterns = merge_anti_patterns(
+            route["route_anti_patterns"],
+            self._extract_anti_patterns(base_context),
+            self._extract_anti_patterns(dynamic_context),
+        )
+        return {
+            "meta": {"query": query, "chapter": chapter, "explicit_genre": genre or ""},
+            "master_setting": {
+                "meta": {
+                    "schema_version": "story-system/v1",
+                    "contract_type": "MASTER_SETTING",
+                    "generator_version": "phase1",
+                    "query": query,
+                },
+                "route": route["meta"],
+                "master_constraints": {
+                    "core_tone": route["core_tone"],
+                    "pacing_strategy": route["pacing_strategy"],
+                },
+                "base_context": base_context,
+                "source_trace": source_trace,
+                "override_policy": {
+                    "locked": ["route.primary_genre", "master_constraints.core_tone"],
+                    "append_only": ["anti_patterns"],
+                    "override_allowed": [],
+                },
+            },
+            "chapter_brief": (
+                {
+                    "meta": {
+                        "schema_version": "story-system/v1",
+                        "contract_type": "CHAPTER_BRIEF",
+                        "generator_version": "phase1",
+                        "chapter": chapter,
+                    },
+                    "override_allowed": {
+                        "chapter_focus": self._suggest_chapter_focus(query, dynamic_context),
+                    },
+                    "dynamic_context": dynamic_context,
+                    "source_trace": source_trace,
+                }
+                if chapter is not None
+                else None
+            ),
+            "anti_patterns": anti_patterns,
+        }
+
+    def _route(self, query: str, genre: Optional[str]) -> Dict[str, Any]:
+        route_rows = self._load_csv_rows("题材与调性推理")
+        query_text = self._normalize_text(" ".join([query or "", genre or ""]))
+
+        # 命中顺序固定:关键词/同义词命中 -> 显式 genre 回退 -> 默认首行回退
+        matched = None
+        for row in route_rows:
+            aliases = self._split_multi_value(row.get("关键词")) + self._split_multi_value(row.get("意图与同义词")) + self._split_multi_value(row.get("题材别名"))
+            if any(alias and alias in query_text for alias in aliases):
+                matched = row
+                route_source = "keyword_or_alias_match"
+                break
+        if matched is None and genre:
+            matched = self._fallback_row_for_genre(route_rows, genre)
+            route_source = "explicit_genre_fallback"
+        if matched is None and route_rows:
+            matched = route_rows[0]
+            route_source = "default_seed_fallback"
+        if matched is None:
+            return self._empty_route(query=query, genre=genre)
+
+        primary_genre = str(matched.get("题材/流派") or genre or "").strip()
+        genre_filter = str(matched.get("适用题材") or genre or primary_genre).strip()
+        return {
+            "meta": {
+                "primary_genre": primary_genre,
+                "route_source": route_source,
+                "genre_filter": genre_filter,
+                "recommended_base_tables": self._split_multi_value(matched.get("推荐基础检索表")),
+                "recommended_dynamic_tables": self._split_multi_value(matched.get("推荐动态检索表")),
+            },
+            "core_tone": str(matched.get("核心调性") or "").strip(),
+            "pacing_strategy": str(matched.get("节奏策略") or "").strip(),
+            "route_anti_patterns": self._extract_route_anti_patterns(matched),
+            "recommended_base_tables": self._split_multi_value(matched.get("推荐基础检索表")),
+            "recommended_dynamic_tables": self._split_multi_value(matched.get("推荐动态检索表")),
+            "genre_filter": genre_filter,
+            "source_trace": [{"table": "题材与调性推理", "id": matched.get("编号", ""), "reason": route_source}],
+        }
+
+    def _collect_tables(self, query: str, tables: List[str], genre: str, top_k: int) -> List[Dict[str, Any]]:
+        rows: List[Dict[str, Any]] = []
+        for table_name in tables:
+            result = search_reference(
+                csv_dir=self.csv_dir,
+                skill="write",
+                query=query,
+                table=table_name,
+                genre=genre,
+                max_results=top_k,
+            )
+            rows.extend(result.get("data", {}).get("results", []))
+        return rows
+
+    def _extract_anti_patterns(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+        extracted: List[Dict[str, Any]] = []
+        for row in rows:
+            table_name = str(row.get("_table") or "")
+            for field_name in ANTI_PATTERN_SOURCE_FIELDS.get(table_name, []):
+                for text in self._split_multi_value(row.get(field_name)):
+                    extracted.append({"text": text, "source_table": table_name, "source_id": row.get("编号", "")})
+        return extracted
+
+    def _suggest_chapter_focus(self, query: str, dynamic_rows: List[Dict[str, Any]]) -> str:
+        for row in dynamic_rows:
+            summary = str(row.get("核心摘要") or "").strip()
+            if summary:
+                return summary
+        return query
+
+    def _build_source_trace(self, *groups: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+        trace: List[Dict[str, Any]] = []
+        for group in groups:
+            for row in group:
+                trace.append(
+                    {
+                        "table": row.get("_table", ""),
+                        "id": row.get("编号", ""),
+                        "summary": row.get("核心摘要", ""),
+                    }
+                )
+        return trace
+
+    def _load_csv_rows(self, table_name: str) -> List[Dict[str, Any]]:
+        csv_path = self.csv_dir / f"{table_name}.csv"
+        if not csv_path.is_file():
+            return []
+        with csv_path.open("r", encoding="utf-8-sig", newline="") as f:
+            return list(csv.DictReader(f))
+
+    def _normalize_text(self, text: str) -> str:
+        return str(text or "").strip().lower()
+
+    def _split_multi_value(self, raw: Any) -> List[str]:
+        return [item.strip() for item in str(raw or "").split("|") if item.strip()]
+
+    def _fallback_row_for_genre(self, rows: List[Dict[str, Any]], genre: str) -> Dict[str, Any] | None:
+        genre = str(genre or "").strip()
+        for row in rows:
+            if genre and genre in self._split_multi_value(row.get("适用题材")):
+                return row
+        return None
+
+    def _extract_route_anti_patterns(self, row: Dict[str, Any]) -> List[Dict[str, Any]]:
+        return [
+            {"text": text, "source_table": "题材与调性推理", "source_id": row.get("编号", "")}
+            for text in self._split_multi_value(row.get("强制禁忌/毒点"))
+        ]
+
+    def _empty_route(self, query: str, genre: Optional[str]) -> Dict[str, Any]:
+        fallback_genre = str(genre or "未命中题材").strip()
+        return {
+            "meta": {
+                "primary_genre": fallback_genre,
+                "route_source": "empty_csv_fallback",
+                "genre_filter": fallback_genre,
+                "recommended_base_tables": ["命名规则", "人设与关系"],
+                "recommended_dynamic_tables": ["桥段套路", "爽点与节奏", "场景写法"],
+            },
+            "core_tone": "",
+            "pacing_strategy": "",
+            "route_anti_patterns": [],
+            "recommended_base_tables": ["命名规则", "人设与关系"],
+            "recommended_dynamic_tables": ["桥段套路", "爽点与节奏", "场景写法"],
+            "genre_filter": fallback_genre,
+            "source_trace": [{"table": "题材与调性推理", "id": "", "reason": f"empty_route_for:{query}"}],
+        }
+```
+
+这里显式约束测试策略:
+
+- 直接使用 `tmp_path / csv` 喂给 `reference_search.search()`
+- 不需要 monkeypatch 搜索函数
+- 如果后续 `reference_search.search()` 签名变化,优先同步这里的聚合器封装,不在测试层绕过真实接口
+
+- [ ] **Step 4: 落地真实 CSV 数据和字段文档**
+
+`题材与调性推理.csv` 至少先录入 3 条手工种子数据,覆盖:
+
+```csv
+编号,适用技能,分类,层级,关键词,意图与同义词,适用题材,大模型指令,核心摘要,详细展开,题材/流派,题材别名,核心调性,节奏策略,主冲突模板,必选爽点,强制禁忌/毒点,推荐基础检索表,推荐动态检索表,基础检索权重,动态检索权重,默认查询词
+GR-001,write|plan,题材路由,知识补充,玄幻退婚流|退婚打脸,退婚流|废材逆袭,玄幻,先压后爆,首个反打必须有羞辱反弹,玄幻退婚流需要耻辱起手和强兑现,,玄幻退婚流,退婚流|废材逆袭,压抑蓄势后爆裂反击,前压后爆,三章内必须首个反打,退婚羞辱→资源争夺→当场反杀,当众反打|身份翻盘,打脸节奏不能缺最后一拍补刀|配角不能压过主角兑现,命名规则|人设与关系|金手指与设定,桥段套路|爽点与节奏|场景写法,命名规则:1.0|人设与关系:0.9,桥段套路:1.0|爽点与节奏:0.9,退婚|打脸|废材逆袭
+GR-002,write|plan,题材路由,知识补充,规则动物园|怪谈副本,规则怪谈|动物园规则,悬疑|轻小说,规则先立死,代价必须兑现,规则动物园重在规则压迫与试错代价,,规则动物园,规则怪谈|动物园规则,诡异压迫与冷感观察,每章至少一个规则验证或误判后果,入园规则→试错牺牲→发现隐藏规则,规则反转|错误成本兑现,规则解释过量|系统提前剧透真相,命名规则|人设与关系,桥段套路|场景写法|写作技法,命名规则:0.7|人设与关系:0.8,场景写法:1.0|写作技法:0.9,规则|动物园|副本
+GR-003,write|plan,题材路由,知识补充,压抑后爆|后期翻盘,压抑一点后面爆|前面憋屈后面翻盘,现言|都市,压抑不能空耗,必须绑定后续兑现资产,压抑后爆路线需要持续累积反弹势能,,压抑后爆,前憋后爆|后期翻盘,持续压迫后的集中爆发,每 2-3 章必须补一个可见反抗信号,压迫累积→误判反扑→情绪总兑现,情绪爆点|身份反转,压抑没有收益|委屈全靠旁白硬说,命名规则|人设与关系|写作技法,爽点与节奏|场景写法|桥段套路,写作技法:0.9|人设与关系:0.8,爽点与节奏:1.0|场景写法:0.8,压抑|翻盘|反弹
+```
+
+同时给 `桥段套路.csv` 增加 `忌讳写法` 列,并在 `references/csv/README.md` 新增:
+
+```markdown
+### 题材与调性推理.csv
+
+| 列名 | 说明 |
+|------|------|
+| `题材/流派` | 路由主标签 |
+| `题材别名` | 同义词 / 平台黑话 |
+| `核心调性` | 全局情绪基调 |
+| `节奏策略` | 开局与兑现节奏 |
+| `强制禁忌/毒点` | 题材级绝对红线 |
+| `推荐基础检索表` | 默认基础检索表 |
+| `推荐动态检索表` | 默认动态检索表 |
+```
+
+- [ ] **Step 5: 回跑测试,确认路由 + 聚合契约转绿**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add webnovel-writer/references/csv/题材与调性推理.csv \
+        webnovel-writer/references/csv/桥段套路.csv \
+        webnovel-writer/references/csv/README.md \
+        webnovel-writer/scripts/data_modules/story_system_engine.py \
+        webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py
+git commit -m "feat: add genre routing csv and story system engine"
+```
+
+---
+
+## Task 3: 实现 `.story-system` 持久化与统一 CLI 接入
+
+**Files:**
+- Create: `webnovel-writer/scripts/story_system.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py`
+- Modify: `webnovel-writer/scripts/data_modules/story_contracts.py`
+- Modify: `webnovel-writer/scripts/data_modules/webnovel.py`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py`
+
+- [ ] **Step 1: 先写 `--persist` 和统一 CLI 转发的失败测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import csv
+import json
+import sys
+
+
+def _write_csv(path, headers, rows):
+    with open(path, "w", encoding="utf-8-sig", newline="") as f:
+        writer = csv.DictWriter(f, fieldnames=headers)
+        writer.writeheader()
+        writer.writerows(rows)
+
+
+def test_story_system_persist_writes_master_chapter_and_anti_patterns(tmp_path, monkeypatch):
+    project_root = tmp_path / "book"
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+
+    csv_dir = tmp_path / "csv"
+    csv_dir.mkdir()
+    _write_csv(
+        csv_dir / "题材与调性推理.csv",
+        [
+            "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
+            "大模型指令", "核心摘要", "详细展开", "题材/流派", "题材别名", "核心调性",
+            "节奏策略", "强制禁忌/毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
+        ],
+        [
+            {
+                "编号": "GR-001",
+                "适用技能": "write",
+                "分类": "题材路由",
+                "层级": "知识补充",
+                "关键词": "玄幻退婚流",
+                "意图与同义词": "退婚流",
+                "适用题材": "玄幻",
+                "大模型指令": "先压后爆",
+                "核心摘要": "退婚起手",
+                "详细展开": "",
+                "题材/流派": "玄幻退婚流",
+                "题材别名": "退婚流",
+                "核心调性": "先压后爆",
+                "节奏策略": "三章内反打",
+                "强制禁忌/毒点": "打脸不能软收尾",
+                "推荐基础检索表": "命名规则",
+                "推荐动态检索表": "桥段套路",
+                "默认查询词": "退婚|打脸",
+            }
+        ],
+    )
+    _write_csv(csv_dir / "命名规则.csv", ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要"], [])
+    _write_csv(csv_dir / "桥段套路.csv", ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "忌讳写法"], [])
+
+    from story_system import main
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "story_system",
+            "玄幻退婚流",
+            "--project-root",
+            str(project_root),
+            "--chapter",
+            "1",
+            "--persist",
+            "--csv-dir",
+            str(csv_dir),
+            "--format",
+            "both",
+        ],
+    )
+    main()
+
+    story_root = project_root / ".story-system"
+    assert (story_root / "MASTER_SETTING.json").is_file()
+    assert (story_root / "MASTER_SETTING.md").is_file()
+    assert (story_root / "anti_patterns.json").is_file()
+    assert (story_root / "chapters" / "chapter_001.json").is_file()
+    assert (story_root / "chapters" / "chapter_001.md").is_file()
+
+    payload = json.loads((story_root / "MASTER_SETTING.json").read_text(encoding="utf-8"))
+    assert payload["route"]["primary_genre"] == "玄幻退婚流"
+
+
+def test_markdown_writer_preserves_manual_notes_outside_markers(tmp_path):
+    from data_modules.story_contracts import write_marked_markdown
+
+    target = tmp_path / "MASTER_SETTING.md"
+    target.write_text(
+        "# 手工说明\n手工备注\n<!-- STORY-SYSTEM:BEGIN -->\n旧内容\n<!-- STORY-SYSTEM:END -->\n",
+        encoding="utf-8",
+    )
+
+    write_marked_markdown(target, "## Auto\n新内容\n")
+
+    text = target.read_text(encoding="utf-8")
+    assert "# 手工说明" in text
+    assert "手工备注" in text
+    assert "## Auto" in text
+    assert "旧内容" not in text
+```
+
+在 `test_webnovel_unified_cli.py` 增加:
+
+```python
+def test_webnovel_story_system_forwards_with_resolved_project_root(monkeypatch, tmp_path):
+    from data_modules import webnovel as cli
+
+    project_root = tmp_path / "book"
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+
+    called = {}
+
+    def _fake_run_script(script_name, argv):
+        called["script_name"] = script_name
+        called["argv"] = argv
+        return 0
+
+    monkeypatch.setattr(cli, "_run_script", _fake_run_script)
+    monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "story-system", "玄幻退婚流"])
+
+    cli.main()
+
+    assert called["script_name"] == "story_system.py"
+    assert called["argv"][:2] == ["--project-root", str(project_root.resolve())]
+```
+
+- [ ] **Step 2: 跑红灯**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py -q --no-cov`
+
+Expected: 失败于 `No module named 'story_system'` 或 `write_marked_markdown` 未实现
+
+- [ ] **Step 3: 实现持久化写入器与 `story_system.py` CLI**
+
+在 `story_contracts.py` 增补持久化函数。这里明确一个边界:**每个 `.md` 文件只允许一组 `<!-- STORY-SYSTEM:BEGIN/END -->` marker**;如果检测到多组 marker,直接抛 `ValueError`,避免 Phase 2 以后出现局部覆盖残留。
+
+```python
+def write_marked_markdown(path: Path, generated_block: str) -> None:
+    wrapped = f"{MARKER_BEGIN}\n{generated_block.rstrip()}\n{MARKER_END}\n"
+    if path.exists():
+        current = path.read_text(encoding="utf-8")
+        if current.count(MARKER_BEGIN) > 1 or current.count(MARKER_END) > 1:
+            raise ValueError(f"{path} contains multiple STORY-SYSTEM markers")
+        if MARKER_BEGIN in current and MARKER_END in current:
+            before, _, rest = current.partition(MARKER_BEGIN)
+            _, _, after = rest.partition(MARKER_END)
+            path.write_text(f"{before}{wrapped}{after.lstrip()}", encoding="utf-8")
+            return
+    path.write_text(wrapped, encoding="utf-8")
+
+
+def render_master_markdown(master_payload: dict) -> str:
+    route = master_payload.get("route") or {}
+    constraints = master_payload.get("master_constraints") or {}
+    return "\n".join(
+        [
+            "# MASTER_SETTING",
+            f"- 题材:{route.get('primary_genre', '')}",
+            f"- 调性:{constraints.get('core_tone', '')}",
+            f"- 节奏:{constraints.get('pacing_strategy', '')}",
+        ]
+    )
+
+
+def render_anti_patterns_markdown(anti_patterns: list[dict]) -> str:
+    lines = ["# ANTI_PATTERNS"]
+    for row in anti_patterns:
+        lines.append(f"- {row.get('text', '')}")
+    return "\n".join(lines)
+
+
+def render_chapter_markdown(chapter_payload: dict) -> str:
+    focus = (chapter_payload.get("override_allowed") or {}).get("chapter_focus", "")
+    return "\n".join(
+        [
+            f"# CHAPTER_{int(chapter_payload['meta']['chapter']):03d}",
+            f"- 章节焦点:{focus}",
+        ]
+    )
+
+
+def persist_story_seed(project_root: Path, master_payload: dict, chapter_payload: dict | None, anti_patterns: list[dict]) -> None:
+    paths = StoryContractPaths.from_project_root(project_root)
+    paths.root.mkdir(parents=True, exist_ok=True)
+    paths.chapters_dir.mkdir(parents=True, exist_ok=True)
+    write_json(paths.master_json, master_payload)
+    write_json(paths.anti_patterns_json, anti_patterns)
+    write_marked_markdown(paths.master_json.with_suffix(".md"), render_master_markdown(master_payload))
+    write_marked_markdown(paths.anti_patterns_json.with_suffix(".md"), render_anti_patterns_markdown(anti_patterns))
+    if chapter_payload is not None:
+        chapter_num = int(chapter_payload["meta"]["chapter"])
+        write_json(paths.chapter_json(chapter_num), chapter_payload)
+        write_marked_markdown(paths.chapter_json(chapter_num).with_suffix(".md"), render_chapter_markdown(chapter_payload))
+```
+
+新增 CLI 入口:
+
+```python
+# webnovel-writer/scripts/story_system.py
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Story System 聚合器")
+    parser.add_argument("query", help="题材描述或当前意图")
+    parser.add_argument("--project-root", help="书项目根目录或工作区根目录")
+    parser.add_argument("--genre", default="", help="显式题材")
+    parser.add_argument("--chapter", type=int, default=0, help="章节号")
+    parser.add_argument("--persist", action="store_true", help="写入 PROJECT_ROOT/.story-system/")
+    parser.add_argument("--format", choices=["json", "markdown", "both"], default="json")
+    parser.add_argument("--csv-dir", default="", help="测试时覆写 CSV 目录")
+    args = parser.parse_args()
+
+    project_root = resolve_project_root(args.project_root) if args.project_root else resolve_project_root()
+    csv_dir = Path(args.csv_dir) if args.csv_dir else Path(__file__).resolve().parent.parent / "references" / "csv"
+
+    engine = StorySystemEngine(csv_dir=csv_dir)
+    payload = engine.build(query=args.query, genre=args.genre or None, chapter=args.chapter or None)
+    if args.persist:
+        persist_story_seed(project_root, payload["master_setting"], payload.get("chapter_brief"), payload["anti_patterns"])
+```
+
+- [ ] **Step 4: 在统一 CLI `webnovel.py` 中挂接 `story-system`**
+
+```python
+# webnovel-writer/scripts/data_modules/webnovel.py
+p_story_system = sub.add_parser("story-system", help="转发到 story_system.py")
+p_story_system.add_argument("args", nargs=argparse.REMAINDER)
+
+# main() 路由分支
+elif args.tool == "story-system":
+    forward_args = ["--project-root", str(project_root), *(_strip_project_root_args(args.args))]
+    raise SystemExit(_run_script("story_system.py", forward_args))
+```
+
+- [ ] **Step 5: 回跑测试,确认持久化与 CLI 契约转绿**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add webnovel-writer/scripts/story_system.py \
+        webnovel-writer/scripts/data_modules/story_contracts.py \
+        webnovel-writer/scripts/data_modules/webnovel.py \
+        webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py \
+        webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py
+git commit -m "feat: persist story system seed contracts and expose unified cli"
+```
+
+---
+
+## Task 4: 把合同种子接到 `context_manager` 与 `extract_chapter_context`
+
+**Files:**
+- Modify: `webnovel-writer/scripts/data_modules/context_manager.py`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_context_manager.py`
+- Modify: `webnovel-writer/scripts/extract_chapter_context.py`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py`
+
+- [ ] **Step 1: 先写合同读取入口的失败测试**
+
+在 `test_context_manager.py` 增加:
+
+```python
+def test_context_manager_includes_story_contract_section_before_genre_profile(temp_project):
+    state = {
+        "genre": "玄幻",
+        "protagonist_state": {"name": "萧炎"},
+        "chapter_meta": {},
+        "disambiguation_warnings": [],
+        "disambiguation_pending": [],
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+
+    story_root = temp_project.project_root / ".story-system"
+    (story_root / "chapters").mkdir(parents=True, exist_ok=True)
+    (story_root / "MASTER_SETTING.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "MASTER_SETTING", "query": "玄幻退婚流"},
+                "route": {"primary_genre": "玄幻退婚流"},
+                "master_constraints": {"core_tone": "先压后爆"},
+                "base_context": [],
+                "source_trace": [],
+                "override_policy": {"locked": ["route.primary_genre"], "append_only": ["anti_patterns"], "override_allowed": []},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "anti_patterns.json").write_text(
+        json.dumps([{"text": "打脸不能软收尾"}], ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (story_root / "chapters" / "chapter_001.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "CHAPTER_BRIEF", "chapter": 1},
+                "override_allowed": {"chapter_focus": "退婚现场反打"},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    payload = ContextManager(temp_project).build_context(1, use_snapshot=False, save_snapshot=False)
+
+    assert "story_contract" in payload["sections"]
+    assert payload["sections"]["story_contract"]["content"]["route"]["primary_genre"] == "玄幻退婚流"
+    assert payload["sections"]["story_contract"]["content"]["chapter_brief"]["override_allowed"]["chapter_focus"] == "退婚现场反打"
+    assert ContextManager.SECTION_ORDER.index("story_contract") < ContextManager.SECTION_ORDER.index("genre_profile")
+```
+
+在 `test_extract_chapter_context.py` 增加:
+
+```python
+def test_build_chapter_context_payload_includes_story_contract(tmp_path):
+    from extract_chapter_context import build_chapter_context_payload
+
+    project_root = tmp_path
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text(
+        json.dumps({"protagonist_state": {}, "chapter_meta": {}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    story_root = project_root / ".story-system"
+    (story_root / "chapters").mkdir(parents=True, exist_ok=True)
+    (story_root / "MASTER_SETTING.json").write_text(
+        json.dumps({"route": {"primary_genre": "规则动物园"}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (story_root / "anti_patterns.json").write_text(
+        json.dumps([{"text": "不要提前解释真相"}], ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (story_root / "chapters" / "chapter_001.json").write_text(
+        json.dumps({"override_allowed": {"chapter_focus": "游客须知初次触发"}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+
+    payload = build_chapter_context_payload(project_root, 1)
+
+    assert payload["story_contract"]["route"]["primary_genre"] == "规则动物园"
+    assert payload["story_contract"]["anti_patterns"][0]["text"] == "不要提前解释真相"
+```
+
+- [ ] **Step 2: 跑红灯**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_context_manager.py webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py -q --no-cov`
+
+Expected: `story_contract` section / payload 字段不存在
+
+- [ ] **Step 3: 在 `context_manager.py` 增加合同加载与 section 注入**
+
+```python
+# webnovel-writer/scripts/data_modules/context_manager.py
+SECTION_ORDER = [
+    "core",
+    "story_contract",
+    "scene",
+    "global",
+    "reader_signal",
+    "genre_profile",
+    "writing_guidance",
+    "plot_structure",
+    "story_skeleton",
+    "memory",
+    "long_term_memory",
+    "preferences",
+    "alerts",
+]
+
+def _load_story_contract(self, chapter: int) -> Dict[str, Any]:
+    paths = StoryContractPaths.from_project_root(self.config.project_root)
+    master = read_json_if_exists(paths.master_json)
+    chapter_payload = read_json_if_exists(paths.chapter_json(chapter))
+    anti_patterns = read_json_if_exists(paths.anti_patterns_json) or []
+    if not master and not chapter_payload and not anti_patterns:
+        return {}
+    return {
+        "master_setting": master,
+        "chapter_brief": chapter_payload,
+        "route": (master or {}).get("route", {}),
+        "master_constraints": (master or {}).get("master_constraints", {}),
+        "anti_patterns": anti_patterns,
+    }
+
+# 在 _build_pack() 组装 pack 时插入:
+story_contract = self._load_story_contract(chapter)
+pack = {
+    "meta": {"chapter": chapter},
+    "core": core,
+    "scene": scene,
+    "global": global_ctx,
+    "reader_signal": reader_signal,
+    "genre_profile": genre_profile,
+    "writing_guidance": writing_guidance,
+    "plot_structure": plot_structure,
+    "story_skeleton": story_skeleton,
+    "memory": memory_ctx,
+    "long_term_memory": long_term_memory,
+    "preferences": preferences,
+    "alerts": alerts,
+}
+if story_contract:
+    pack["story_contract"] = story_contract
+```
+
+- [ ] **Step 4: 在 `extract_chapter_context.py` 中透出 `story_contract`**
+
+```python
+# webnovel-writer/scripts/extract_chapter_context.py
+def _load_contract_context(project_root: Path, chapter_num: int) -> Dict[str, Any]:
+    manager = ContextManager(get_config(project_root))
+    payload = manager.build_context(chapter_num, use_snapshot=False, save_snapshot=False)
+    sections = payload.get("sections") or {}
+    return {
+        "reader_signal": (sections.get("reader_signal") or {}).get("content", {}),
+        "genre_profile": (sections.get("genre_profile") or {}).get("content", {}),
+        "story_contract": (sections.get("story_contract") or {}).get("content", {}),
+        "writing_guidance": (sections.get("writing_guidance") or {}).get("content", {}),
+        "plot_structure": (sections.get("plot_structure") or {}).get("content", {}),
+        "long_term_memory": (sections.get("long_term_memory") or {}).get("content", {}),
+    }
+```
+
+文本渲染新增一个紧凑章节:
+
+```python
+story_contract = payload.get("story_contract") or {}
+if story_contract:
+    lines.append("## Story Contract")
+    route = story_contract.get("route") or {}
+    if route.get("primary_genre"):
+        lines.append(f"- 主路由题材: {route['primary_genre']}")
+    anti_patterns = story_contract.get("anti_patterns") or []
+    for row in anti_patterns[:5]:
+        lines.append(f"- 红线: {row.get('text')}")
+```
+
+- [ ] **Step 5: 回跑测试**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_context_manager.py webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add webnovel-writer/scripts/data_modules/context_manager.py \
+        webnovel-writer/scripts/data_modules/tests/test_context_manager.py \
+        webnovel-writer/scripts/extract_chapter_context.py \
+        webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py
+git commit -m "feat: load story contract seed into context assembly"
+```
+
+---
+
+## Task 5: 更新架构文档、命令文档与回归验证
+
+**Files:**
+- Create: `docs/architecture/story-system-phase1.md`
+- Modify: `README.md`
+- Modify: `docs/architecture/overview.md`
+- Modify: `docs/guides/commands.md`
+- Modify: `docs/superpowers/README.md`
+
+- [ ] **Step 1: 新建 Phase 1 架构文档**
+
+`docs/architecture/story-system-phase1.md` 至少写清楚以下四段,避免 Phase 1 代码上线后又变成“隐式约定”:
+
+```markdown
+# Story System Phase 1
+
+## JSON 真源
+- `PROJECT_ROOT/.story-system/MASTER_SETTING.json`
+- `PROJECT_ROOT/.story-system/anti_patterns.json`
+- `PROJECT_ROOT/.story-system/chapters/chapter_XXX.json`
+
+## 覆盖规则
+- `locked`:chapter 不得覆盖
+- `append_only`:chapter 只能补充
+- `override_allowed`:chapter 可局部覆盖
+
+## 运行时读取顺序
+1. chapter brief
+2. master setting
+3. anti-patterns
+4. genre profile fallback
+
+## 迁移边界
+- Phase 1 不引入 `VOLUME_BRIEF`
+- Phase 1 不改写后回写主链
+- `genre-profiles.md` 继续保留为回退层
+```
+
+- [ ] **Step 2: 更新命令与总览文档**
+
+在 `docs/guides/commands.md` 增加:
+
+````markdown
+## Story System(Phase 1)
+
+```bash
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" \
+  --project-root "<WORKSPACE_ROOT>" \
+  story-system "玄幻退婚流" --chapter 1 --persist --format both
+```
+
+说明:
+- `--project-root` 允许传工作区根或书项目根
+- 真实落盘位置始终是 `PROJECT_ROOT/.story-system/`
+- `*.json` 为真源,`*.md` 为投影视图
+````
+
+在 `README.md` 与 `docs/architecture/overview.md` 补一条 Phase 1 说明:
+
+```markdown
+- Story System Phase 1:新增最小合同种子层(`MASTER_SETTING` / `chapter_XXX` / `anti_patterns`),
+  作为 `context_manager` 的合同输入前置层。
+```
+
+- [ ] **Step 3: 更新 `docs/superpowers/README.md` 导航**
+
+```markdown
+- [`plans/2026-04-12-story-system-phase1-contract-seed.md`](./plans/2026-04-12-story-system-phase1-contract-seed.md):Story System Phase 1 合同种子层实施计划
+```
+
+- [ ] **Step 4: 跑 Phase 1 目标测试集**
+
+Run:
+
+```bash
+python -m pytest \
+  webnovel-writer/scripts/data_modules/tests/test_story_contracts.py \
+  webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py \
+  webnovel-writer/scripts/data_modules/tests/test_story_system_cli.py \
+  webnovel-writer/scripts/data_modules/tests/test_context_manager.py \
+  webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py \
+  webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py \
+  -q --no-cov
+```
+
+Expected: 全部通过
+
+- [ ] **Step 5: 跑 `reference_search.py` 回归,证明没有破坏底层 primitive**
+
+Run: `python -m pytest webnovel-writer/scripts/tests/test_reference_search.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 6: 最终提交**
+
+```bash
+git add README.md \
+        docs/architecture/story-system-phase1.md \
+        docs/architecture/overview.md \
+        docs/guides/commands.md \
+        docs/superpowers/README.md
+git commit -m "docs: document story system phase1 contract seed layer"
+```
+
+---
+
+## Spec Coverage Check
+
+本计划对 `2026-04-12-story-system-evolution-spec.md` 的覆盖关系如下:
+
+- `13.2 Phase 1:合同种子层`
+  - `题材与调性推理.csv`:Task 2
+  - 最小 `MASTER_SETTING`:Task 3
+  - 最小 `CHAPTER_BRIEF`:Task 3
+  - `anti_patterns.json`:Task 3
+  - `context_manager` 读取合同:Task 4
+
+- `14.1 / 14.1.1 路径解析约束`
+  - `PROJECT_ROOT/.story-system`:Task 1 / Task 3
+  - `resolve_project_root(args.project_root)` 经 unified CLI 注入:Task 3
+
+- `15.3 当前阶段结论`
+  - 保留 CSV + MD 双体系,不做自动迁移:Task 2
+
+- `17.1 文档更新要求`
+  - 合同 schema / 目录 / 运行流程 / 迁移说明文档:Task 5
+
+- `19. 实施建议`
+  - 明确先做合同种子层,不提前做 `CHAPTER_COMMIT` 或 event log:全计划范围
+
+---
+
+## Placeholder Scan
+
+已避免以下占位式写法:
+
+- 没有使用 “TODO / TBD / 后续补”
+- 没有把“写测试”写成空泛口号,均给出测试骨架
+- 没有把文档更新写成一句“同步更新文档”,而是明确到目标文件
+- 没有把 Phase 2-4 内容混进 Phase 1 实施任务
+
+---
+
+## Next Plan
+
+Phase 1 完成并稳定后,再进入下一份计划:
+
+1. `Phase 2 Contract-First Runtime`:`VOLUME_BRIEF`、`REVIEW_CONTRACT`、写前禁区、履约 diff
+2. `Phase 3 Chapter Commit Chain`:`CHAPTER_COMMIT`、accepted/rejected 语义、projection writers
+3. `Phase 4 Event Log + Override Ledger`:canonical event log、`contract_override` / `amend_proposal`

+ 809 - 0
docs/superpowers/plans/2026-04-12-story-system-phase2-contract-first-runtime.md

@@ -0,0 +1,809 @@
+# Story System Phase 2 Contract-First Runtime Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 在 Phase 1 合同种子层之上,落地 `VOLUME_BRIEF`、`REVIEW_CONTRACT`、写前禁区与消歧域、大纲履约 diff、`context_manager` contract-first pack,让规划/写作/审查默认先消费合同而不是临时拼资料。
+
+**Architecture:** 以 `.story-system/*.json` 为唯一合同真源,在 Phase 1 的 `MASTER_SETTING / CHAPTER_BRIEF / anti_patterns` 基础上新增卷级与审查级合同,并把 Phase 1 的扁平聚合结果拆成 schema 化合同家族。运行时遵循 `chapter -> volume -> master -> old profile/reference fallback` 的固定优先级;Markdown 在本阶段退化为 JSON 的只读渲染产物。
+
+**Tech Stack:** Python 3.13, Pydantic, argparse, pytest, unified CLI `webnovel.py`, Markdown + JSON contract artifacts
+
+**Spec:** `docs/superpowers/specs/2026-04-12-story-system-evolution-spec.md`
+
+**Companion Specs:** `docs/superpowers/specs/2026-04-12-webnovel-story-intelligence-system-spec.md`, `docs/superpowers/plans/2026-04-12-story-system-phase1-contract-seed.md`
+
+---
+
+## Scope Split
+
+本计划只覆盖 Phase 2:
+
+1. `VOLUME_BRIEF`
+2. `REVIEW_CONTRACT`
+3. 写前禁区与消歧域
+4. 大纲履约 diff
+5. `context_manager` contract-first pack
+
+明确不做:
+
+- 不引入 `CHAPTER_COMMIT`
+- 不把写后回写改成 commit 驱动
+- 不建立 canonical event log
+- 不做 override ledger 扩展迁移
+
+本阶段的退出标准:
+
+1. `MASTER / VOLUME / CHAPTER / REVIEW` 都有稳定 JSON schema
+2. `context_manager` 能按合同优先级输出 pack
+3. 写前能产出 `prewrite_validation` 与 `fulfillment_seed`
+4. `webnovel-plan` / `webnovel-write` / `webnovel-review` 的默认读取顺序已切到合同优先
+5. `genre-profiles.md` 与旧 reference 只作为 fallback,不再并列充当系统判断真源
+
+文档更新沿用 Phase 1 已建好的 `Story System` 段落:
+
+- Phase 2 只追加 `contract-first runtime`、`VOLUME_BRIEF`、`REVIEW_CONTRACT`
+- 不重写 `README.md / overview.md / commands.md` 的总体结构
+
+---
+
+## File Structure
+
+### 要创建的文件
+
+- `webnovel-writer/scripts/data_modules/story_contract_schema.py`
+- `webnovel-writer/scripts/data_modules/runtime_contract_builder.py`
+- `webnovel-writer/scripts/data_modules/prewrite_validator.py`
+- `webnovel-writer/scripts/data_modules/tests/test_story_contract_schema.py`
+- `webnovel-writer/scripts/data_modules/tests/test_runtime_contract_builder.py`
+- `webnovel-writer/scripts/data_modules/tests/test_prewrite_validator.py`
+- `docs/architecture/story-system-phase2.md`
+
+### 要修改的文件
+
+- `webnovel-writer/scripts/data_modules/story_contracts.py`
+- `webnovel-writer/scripts/data_modules/story_system_engine.py`
+- `webnovel-writer/scripts/story_system.py`
+- `webnovel-writer/scripts/data_modules/context_manager.py`
+- `webnovel-writer/scripts/data_modules/tests/test_context_manager.py`
+- `webnovel-writer/scripts/extract_chapter_context.py`
+- `webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py`
+- `webnovel-writer/scripts/chapter_outline_loader.py`
+- `webnovel-writer/scripts/data_modules/webnovel.py`
+- `webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py`
+- `webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py`
+- `webnovel-writer/skills/webnovel-plan/SKILL.md`
+- `webnovel-writer/skills/webnovel-write/SKILL.md`
+- `webnovel-writer/skills/webnovel-review/SKILL.md`
+- `README.md`
+- `docs/architecture/overview.md`
+- `docs/guides/commands.md`
+- `docs/superpowers/README.md`
+
+### 文件职责
+
+- `story_contract_schema.py`:`MASTER_SETTING / VOLUME_BRIEF / CHAPTER_BRIEF / REVIEW_CONTRACT` 的 Pydantic schema 与版本元数据
+- `runtime_contract_builder.py`:从 Phase 1 seed、卷范围、大纲结构、plot structure 生成 `VOLUME_BRIEF` 与 `REVIEW_CONTRACT`
+- `prewrite_validator.py`:写前禁区检查、消歧域构建、履约 seed 与 must-check 列表生成
+- `story_contracts.py`:新增 `volumes/`、`reviews/` 路径、JSON 真源写入、只读 Markdown 重建
+- `context_manager.py`:把 contract-first pack 作为默认装配顺序
+- `skills/*/SKILL.md`:把运行时入口切换为先生成/读取合同,再写作或审查
+
+---
+
+## Task 1: 建立 Phase 2 合同 schema 与目录扩展
+
+**Files:**
+- Create: `webnovel-writer/scripts/data_modules/story_contract_schema.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_story_contract_schema.py`
+- Modify: `webnovel-writer/scripts/data_modules/story_contracts.py`
+
+- [ ] **Step 1: 先写 schema 失败测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_story_contract_schema.py
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import pytest
+
+from data_modules.story_contract_schema import ChapterBrief, MasterSetting, ReviewContract, VolumeBrief
+
+
+def test_master_setting_and_chapter_brief_accept_phase1_seed_shape():
+    master = MasterSetting.model_validate(
+        {
+            "meta": {"schema_version": "story-system/v1", "contract_type": "MASTER_SETTING"},
+            "route": {"primary_genre": "玄幻退婚流"},
+            "master_constraints": {"core_tone": "先压后爆", "pacing_strategy": "三章内首个反打"},
+            "base_context": [],
+            "source_trace": [],
+            "override_policy": {"locked": ["route.primary_genre"], "append_only": ["anti_patterns"], "override_allowed": []},
+        }
+    )
+    chapter = ChapterBrief.model_validate(
+        {
+            "meta": {"schema_version": "story-system/v1", "contract_type": "CHAPTER_BRIEF"},
+            "override_allowed": {"chapter_focus": "退婚现场反打"},
+            "dynamic_context": [],
+            "source_trace": [],
+        }
+    )
+    assert master.route["primary_genre"] == "玄幻退婚流"
+    assert chapter.override_allowed["chapter_focus"] == "退婚现场反打"
+
+
+def test_volume_brief_requires_selected_fields():
+    payload = {
+        "meta": {"schema_version": "story-system/v1", "contract_type": "VOLUME_BRIEF"},
+        "volume_goal": {"summary": "卷一站稳脚跟"},
+        "selected_tropes": ["退婚反击"],
+        "selected_pacing": {"wave": "压抑后爆"},
+        "selected_scenes": ["宗门大厅", "资源争夺"],
+        "anti_patterns": ["配角抢主角兑现"],
+        "system_constraints": ["金手指每日限一次"],
+        "overrides": {"locked": {}, "append_only": {}, "override_allowed": {}},
+    }
+    model = VolumeBrief.model_validate(payload)
+    assert model.volume_goal["summary"] == "卷一站稳脚跟"
+
+
+def test_review_contract_requires_blocking_rules_list():
+    with pytest.raises(Exception):
+        ReviewContract.model_validate(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "REVIEW_CONTRACT"},
+                "must_check": ["mandatory_nodes"],
+                "blocking_rules": "not-a-list",
+                "genre_specific_risks": [],
+                "anti_patterns": [],
+                "system_constraints": [],
+                "review_thresholds": {"blocking_count": 0},
+                "overrides": {"locked": {}, "append_only": {}, "override_allowed": {}},
+            }
+        )
+```
+
+- [ ] **Step 2: 运行测试确认红灯**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_contract_schema.py -q --no-cov`
+
+Expected: `ModuleNotFoundError: No module named 'data_modules.story_contract_schema'`
+
+- [ ] **Step 3: 实现 Phase 2 schema 与目录路径**
+
+```python
+# webnovel-writer/scripts/data_modules/story_contract_schema.py
+from __future__ import annotations
+
+from typing import Any, Dict, List
+
+from pydantic import BaseModel, Field
+
+
+class ContractMeta(BaseModel):
+    schema_version: str = "story-system/v1"
+    contract_type: str
+    generator_version: str = "phase2"
+    source_trace: List[Dict[str, Any]] = Field(default_factory=list)
+
+
+class OverrideBundle(BaseModel):
+    locked: Dict[str, Any] = Field(default_factory=dict)
+    append_only: Dict[str, Any] = Field(default_factory=dict)
+    override_allowed: Dict[str, Any] = Field(default_factory=dict)
+
+
+class MasterSetting(BaseModel):
+    meta: ContractMeta
+    route: Dict[str, Any] = Field(default_factory=dict)
+    master_constraints: Dict[str, Any] = Field(default_factory=dict)
+    base_context: List[Dict[str, Any]] = Field(default_factory=list)
+    source_trace: List[Dict[str, Any]] = Field(default_factory=list)
+    override_policy: Dict[str, List[str]] = Field(default_factory=dict)
+
+
+class ChapterBrief(BaseModel):
+    meta: ContractMeta
+    override_allowed: Dict[str, Any] = Field(default_factory=dict)
+    dynamic_context: List[Dict[str, Any]] = Field(default_factory=list)
+    source_trace: List[Dict[str, Any]] = Field(default_factory=list)
+
+
+class VolumeBrief(BaseModel):
+    meta: ContractMeta
+    volume_goal: Dict[str, Any]
+    selected_tropes: List[str] = Field(default_factory=list)
+    selected_pacing: Dict[str, Any] = Field(default_factory=dict)
+    selected_scenes: List[str] = Field(default_factory=list)
+    anti_patterns: List[str] = Field(default_factory=list)
+    system_constraints: List[str] = Field(default_factory=list)
+    overrides: OverrideBundle = Field(default_factory=OverrideBundle)
+
+
+class ReviewContract(BaseModel):
+    meta: ContractMeta
+    must_check: List[str] = Field(default_factory=list)
+    blocking_rules: List[str] = Field(default_factory=list)
+    genre_specific_risks: List[str] = Field(default_factory=list)
+    anti_patterns: List[str] = Field(default_factory=list)
+    system_constraints: List[str] = Field(default_factory=list)
+    review_thresholds: Dict[str, Any] = Field(default_factory=dict)
+    overrides: OverrideBundle = Field(default_factory=OverrideBundle)
+```
+
+`blocking_rules` 在 Phase 2 先保持 `List[str]`,避免过早引入复杂 schema;但在文档中明确标注它是 **Phase 5 可升级为 `List[BlockingRule]` 的预留位**,后续如果要附带严重级、来源和匹配模式,再做结构化收口。
+
+- [ ] **Step 4: 扩展 `story_contracts.py` 的卷级/审查级路径**
+
+```python
+# webnovel-writer/scripts/data_modules/story_contracts.py
+@property
+def volumes_dir(self) -> Path:
+    return self.root / "volumes"
+
+@property
+def reviews_dir(self) -> Path:
+    return self.root / "reviews"
+
+def volume_json(self, volume: int) -> Path:
+    return self.volumes_dir / f"volume_{volume:03d}.json"
+
+def review_json(self, chapter: int) -> Path:
+    return self.reviews_dir / f"chapter_{chapter:03d}.review.json"
+```
+
+- [ ] **Step 5: 回跑测试**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_contract_schema.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add webnovel-writer/scripts/data_modules/story_contract_schema.py \
+        webnovel-writer/scripts/data_modules/story_contracts.py \
+        webnovel-writer/scripts/data_modules/tests/test_story_contract_schema.py
+git commit -m "feat: add phase2 contract schemas and paths"
+```
+
+---
+
+## Task 2: 生成 `VOLUME_BRIEF` 与 `REVIEW_CONTRACT`
+
+**Files:**
+- Create: `webnovel-writer/scripts/data_modules/runtime_contract_builder.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_runtime_contract_builder.py`
+- Modify: `webnovel-writer/scripts/data_modules/story_system_engine.py`
+- Modify: `webnovel-writer/scripts/data_modules/story_contracts.py`
+- Modify: `webnovel-writer/scripts/story_system.py`
+- Modify: `webnovel-writer/scripts/chapter_outline_loader.py`
+
+- [ ] **Step 1: 先写生成器测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_runtime_contract_builder.py
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import json
+
+from data_modules.runtime_contract_builder import RuntimeContractBuilder
+
+
+def test_runtime_contract_builder_creates_volume_and_review_contracts(tmp_path):
+    project_root = tmp_path
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text(
+        json.dumps(
+            {
+                "progress": {"volumes_planned": [{"volume": 1, "chapters_range": "1-20"}]},
+                "chapter_meta": {},
+                "disambiguation_pending": [],
+                "disambiguation_warnings": [],
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (project_root / ".story-system" / "MASTER_SETTING.json").parent.mkdir(parents=True, exist_ok=True)
+    (project_root / ".story-system" / "MASTER_SETTING.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "MASTER_SETTING"},
+                "route": {"primary_genre": "玄幻退婚流"},
+                "master_constraints": {"core_tone": "先压后爆"},
+                "base_context": [],
+                "source_trace": [],
+                "override_policy": {"locked": ["route.primary_genre"], "append_only": ["anti_patterns"], "override_allowed": []},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (project_root / ".story-system" / "anti_patterns.json").write_text(
+        json.dumps([{"text": "配角不能抢主角兑现"}], ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (project_root / "大纲").mkdir(parents=True, exist_ok=True)
+    (project_root / "大纲" / "第1卷-详细大纲.md").write_text(
+        "### 第3章:试压\\nCBN:继续压迫\\n必须覆盖节点:发现陷阱、决定隐忍\\n本章禁区:不可提前摊牌",
+        encoding="utf-8",
+    )
+
+    builder = RuntimeContractBuilder(project_root)
+    volume_brief, review_contract = builder.build_for_chapter(3)
+
+    assert volume_brief["meta"]["contract_type"] == "VOLUME_BRIEF"
+    assert review_contract["meta"]["contract_type"] == "REVIEW_CONTRACT"
+    assert "发现陷阱" in review_contract["must_check"]
+    assert "不可提前摊牌" in review_contract["blocking_rules"]
+```
+
+- [ ] **Step 2: 跑红灯**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_runtime_contract_builder.py -q --no-cov`
+
+Expected: `ModuleNotFoundError: No module named 'data_modules.runtime_contract_builder'`
+
+- [ ] **Step 3: 实现生成器**
+
+```python
+# webnovel-writer/scripts/data_modules/runtime_contract_builder.py
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any, Dict, Tuple
+
+from chapter_outline_loader import load_chapter_plot_structure, volume_num_for_chapter_from_state
+
+from data_modules.story_contract_schema import MasterSetting, ReviewContract, VolumeBrief
+
+
+class RuntimeContractBuilder:
+    def __init__(self, project_root: Path):
+        self.project_root = Path(project_root)
+
+    def build_for_chapter(self, chapter: int) -> Tuple[Dict[str, Any], Dict[str, Any]]:
+        master = self._load_master_setting()
+        anti_patterns = self._load_anti_patterns()
+        plot = self._load_plot_structure(chapter)
+        volume = self._resolve_volume(chapter)
+
+        volume_brief = VolumeBrief.model_validate(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "VOLUME_BRIEF"},
+                "volume_goal": {"summary": f"第{volume}卷延续 {master.route.get('primary_genre', '')} 的主冲突"},
+                "selected_tropes": [master.route.get("primary_genre", "")],
+                "selected_pacing": {"wave": master.master_constraints.get("pacing_strategy", "")},
+                "selected_scenes": list(plot.get("cpns") or []),
+                "anti_patterns": [row.get("text", "") for row in anti_patterns if row.get("text")],
+                "system_constraints": [master.master_constraints.get("core_tone", "")],
+                "overrides": {"locked": {}, "append_only": {}, "override_allowed": {}},
+            }
+        ).model_dump()
+        review_contract = ReviewContract.model_validate(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "REVIEW_CONTRACT"},
+                "must_check": list(plot.get("mandatory_nodes") or []),
+                "blocking_rules": list(plot.get("prohibitions") or []),
+                "genre_specific_risks": [master.route.get("primary_genre", "")],
+                "anti_patterns": volume_brief["anti_patterns"],
+                "system_constraints": volume_brief["system_constraints"],
+                "review_thresholds": {"blocking_count": 0, "missed_nodes": 0},
+                "overrides": {"locked": {}, "append_only": {}, "override_allowed": {}},
+            }
+        ).model_dump()
+        return volume_brief, review_contract
+
+    def _load_master_setting(self) -> MasterSetting:
+        raw = json.loads((self.project_root / ".story-system" / "MASTER_SETTING.json").read_text(encoding="utf-8"))
+        return MasterSetting.model_validate(raw)
+
+    def _load_anti_patterns(self) -> list[Dict[str, Any]]:
+        raw = json.loads((self.project_root / ".story-system" / "anti_patterns.json").read_text(encoding="utf-8"))
+        return list(raw or [])
+
+    def _load_plot_structure(self, chapter: int) -> Dict[str, Any]:
+        raw = load_chapter_plot_structure(self.project_root, chapter) or {}
+        return {
+            "mandatory_nodes": list(raw.get("mandatory_nodes") or []),
+            "prohibitions": list(raw.get("prohibitions") or []),
+            "cpns": list(raw.get("cpns") or []),
+        }
+
+    def _resolve_volume(self, chapter: int) -> int:
+        return volume_num_for_chapter_from_state(self.project_root, chapter) or 1
+```
+
+- [ ] **Step 4: 在 `story_system.py` 中新增 `build-runtime-contracts` 入口**
+
+```python
+# webnovel-writer/scripts/data_modules/story_contracts.py
+from chapter_outline_loader import volume_num_for_chapter_from_state
+
+
+def persist_runtime_contracts(project_root: Path, chapter: int, volume_brief: dict, review_contract: dict) -> None:
+    paths = StoryContractPaths.from_project_root(project_root)
+    volume = volume_num_for_chapter_from_state(project_root, chapter) or 1
+    paths.volumes_dir.mkdir(parents=True, exist_ok=True)
+    paths.reviews_dir.mkdir(parents=True, exist_ok=True)
+    write_json(paths.volume_json(volume), volume_brief)
+    write_json(paths.review_json(chapter), review_contract)
+
+# webnovel-writer/scripts/story_system.py
+parser.add_argument("--emit-runtime-contracts", action="store_true")
+
+if args.emit_runtime_contracts:
+    builder = RuntimeContractBuilder(project_root)
+    volume_brief, review_contract = builder.build_for_chapter(args.chapter)
+    persist_runtime_contracts(project_root, args.chapter, volume_brief, review_contract)
+```
+
+- [ ] **Step 5: 回跑测试**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_runtime_contract_builder.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add webnovel-writer/scripts/data_modules/runtime_contract_builder.py \
+        webnovel-writer/scripts/data_modules/tests/test_runtime_contract_builder.py \
+        webnovel-writer/scripts/data_modules/story_contracts.py \
+        webnovel-writer/scripts/story_system.py \
+        webnovel-writer/scripts/chapter_outline_loader.py
+git commit -m "feat: generate volume brief and review contract"
+```
+
+---
+
+## Task 3: 写前禁区、消歧域与大纲履约 diff
+
+**Files:**
+- Create: `webnovel-writer/scripts/data_modules/prewrite_validator.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_prewrite_validator.py`
+- Modify: `webnovel-writer/scripts/data_modules/context_manager.py`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_context_manager.py`
+
+- [ ] **Step 1: 先写 `prewrite_validator` 失败测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_prewrite_validator.py
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import json
+
+from data_modules.prewrite_validator import PrewriteValidator
+
+
+def test_prewrite_validator_builds_disambiguation_domain_and_fulfillment_seed(tmp_path):
+    project_root = tmp_path
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text(
+        json.dumps(
+            {
+                "disambiguation_pending": [],
+                "disambiguation_warnings": [{"mention": "宗主"}],
+                "chapter_meta": {},
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    review_contract = {"must_check": ["发现陷阱"], "blocking_rules": ["不可提前摊牌"]}
+    plot_structure = {"mandatory_nodes": ["发现陷阱"], "prohibitions": ["不可提前摊牌"]}
+
+    payload = PrewriteValidator(project_root).build(chapter=3, review_contract=review_contract, plot_structure=plot_structure)
+
+    assert payload["blocking"] is False
+    assert payload["fulfillment_seed"]["planned_nodes"] == ["发现陷阱"]
+    assert payload["disambiguation_domain"]["pending_count"] == 0
+```
+
+- [ ] **Step 2: 跑红灯**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_prewrite_validator.py -q --no-cov`
+
+Expected: `ModuleNotFoundError: No module named 'data_modules.prewrite_validator'`
+
+- [ ] **Step 3: 实现写前校验器**
+
+```python
+# webnovel-writer/scripts/data_modules/prewrite_validator.py
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any, Dict
+
+
+class PrewriteValidator:
+    def __init__(self, project_root: Path):
+        self.project_root = Path(project_root)
+
+    def build(self, chapter: int, review_contract: Dict[str, Any], plot_structure: Dict[str, Any]) -> Dict[str, Any]:
+        state = json.loads((self.project_root / ".webnovel" / "state.json").read_text(encoding="utf-8"))
+        pending = state.get("disambiguation_pending") or []
+        warnings = state.get("disambiguation_warnings") or []
+        return {
+            "chapter": chapter,
+            "blocking": bool(pending),
+            "blocking_reasons": ["存在高优先级 disambiguation_pending"] if pending else [],
+            "forbidden_zones": list(review_contract.get("blocking_rules") or []),
+            "disambiguation_domain": {
+                "pending_count": len(pending),
+                "warning_count": len(warnings),
+                "allowed_mentions": [item.get("mention", "") for item in warnings if item.get("mention")],
+            },
+            "fulfillment_seed": {
+                "planned_nodes": list(plot_structure.get("mandatory_nodes") or []),
+                "prohibitions": list(plot_structure.get("prohibitions") or []),
+            },
+        }
+```
+
+- [ ] **Step 4: 在 `context_manager` 中新增 `prewrite_validation` section**
+
+```python
+# webnovel-writer/scripts/data_modules/context_manager.py
+SECTION_ORDER = [
+    "core",
+    "story_contract",
+    "prewrite_validation",
+    "scene",
+    "global",
+    "reader_signal",
+    "genre_profile",
+    "writing_guidance",
+    "plot_structure",
+    "story_skeleton",
+    "memory",
+    "long_term_memory",
+    "preferences",
+    "alerts",
+]
+
+validator = PrewriteValidator(self.config.project_root)
+pack["prewrite_validation"] = validator.build(
+    chapter=chapter,
+    review_contract=story_contract.get("review_contract") or {},
+    plot_structure=plot_structure,
+)
+```
+
+- [ ] **Step 5: 回跑测试**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_prewrite_validator.py webnovel-writer/scripts/data_modules/tests/test_context_manager.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add webnovel-writer/scripts/data_modules/prewrite_validator.py \
+        webnovel-writer/scripts/data_modules/tests/test_prewrite_validator.py \
+        webnovel-writer/scripts/data_modules/context_manager.py \
+        webnovel-writer/scripts/data_modules/tests/test_context_manager.py
+git commit -m "feat: add prewrite validation and fulfillment seed"
+```
+
+---
+
+## Task 4: 切换 runtime 为 contract-first,并接入 skills / CLI
+
+**Files:**
+- Modify: `webnovel-writer/scripts/extract_chapter_context.py`
+- Modify: `webnovel-writer/scripts/data_modules/webnovel.py`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py`
+- Modify: `webnovel-writer/skills/webnovel-plan/SKILL.md`
+- Modify: `webnovel-writer/skills/webnovel-write/SKILL.md`
+- Modify: `webnovel-writer/skills/webnovel-review/SKILL.md`
+
+- [ ] **Step 1: 先补 contract-first 读取顺序测试**
+
+在 `test_prompt_integrity.py` 增加更稳的“步骤块”断言,而不是裸字符串包含:
+
+```python
+def test_story_system_runtime_contract_commands_exist():
+    import re
+
+    text = Path("webnovel-writer/skills/webnovel-write/SKILL.md").read_text(encoding="utf-8")
+    block = re.search(r"story-system[\\s\\S]+--emit-runtime-contracts[\\s\\S]+REVIEW_CONTRACT", text)
+    assert block, "webnovel-write skill 必须包含生成 runtime contracts 的完整步骤块"
+```
+
+在 `test_webnovel_unified_cli.py` 增加:
+
+```python
+def test_webnovel_story_system_runtime_forwards(monkeypatch, tmp_path):
+    from data_modules import webnovel as cli
+    project_root = tmp_path / "book"
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    called = {}
+
+    def _fake_run_script(script_name, argv):
+        called["script_name"] = script_name
+        called["argv"] = argv
+        return 0
+
+    monkeypatch.setattr(cli, "_run_script", _fake_run_script)
+    monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "story-system", "玄幻退婚流", "--emit-runtime-contracts"])
+    cli.main()
+
+    assert called["script_name"] == "story_system.py"
+    assert "--emit-runtime-contracts" in called["argv"]
+```
+
+- [ ] **Step 2: 跑红灯**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py -q --no-cov`
+
+Expected: 新断言失败
+
+- [ ] **Step 3: 修改技能与文本提取脚本**
+
+在三个 skill 中统一插入 Phase 2 前置步骤:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" \
+  story-system "{chapter_goal}" --chapter {chapter_num} --persist --emit-runtime-contracts --format both
+```
+
+并在 `extract_chapter_context.py` 输出:
+
+```python
+story_contract = contract_context.get("story_contract") or {}
+review_contract = story_contract.get("review_contract") or {}
+prewrite_validation = payload.get("prewrite_validation") or {}
+
+lines.append("## Contract-First Runtime")
+lines.append(f"- Review blocking rules: {len(review_contract.get('blocking_rules') or [])}")
+lines.append(f"- Prewrite blocking: {prewrite_validation.get('blocking')}")
+```
+
+- [ ] **Step 4: 回跑测试**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add webnovel-writer/scripts/extract_chapter_context.py \
+        webnovel-writer/scripts/data_modules/webnovel.py \
+        webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py \
+        webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py \
+        webnovel-writer/skills/webnovel-plan/SKILL.md \
+        webnovel-writer/skills/webnovel-write/SKILL.md \
+        webnovel-writer/skills/webnovel-review/SKILL.md
+git commit -m "feat: switch runtime entrypoints to contract-first flow"
+```
+
+---
+
+## Task 5: 文档更新与 Phase 2 回归验证
+
+**Files:**
+- Create: `docs/architecture/story-system-phase2.md`
+- Modify: `README.md`
+- Modify: `docs/architecture/overview.md`
+- Modify: `docs/guides/commands.md`
+- Modify: `docs/superpowers/README.md`
+
+- [ ] **Step 1: 新建 Phase 2 架构文档**
+
+`docs/architecture/story-system-phase2.md` 必须至少包含:
+
+```markdown
+# Story System Phase 2
+
+## 合同真源
+- `MASTER_SETTING.json`
+- `volumes/volume_XXX.json`
+- `chapters/chapter_XXX.json`
+- `reviews/chapter_XXX.review.json`
+
+## 运行时顺序
+1. chapter brief
+2. volume brief
+3. master setting
+4. fallback references
+
+## 写前校验
+- forbidden zones
+- disambiguation domain
+- fulfillment seed
+
+## 非目标
+- 不引入 `CHAPTER_COMMIT`
+- 不引入 canonical event log
+```
+
+- [ ] **Step 2: 更新命令文档**
+
+在 `docs/guides/commands.md` 增加:
+
+```bash
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" \
+  --project-root "<WORKSPACE_ROOT>" \
+  story-system "玄幻退婚流" --chapter 3 --persist --emit-runtime-contracts --format both
+```
+
+- [ ] **Step 3: 跑 Phase 2 最小验证集**
+
+Run:
+
+```bash
+python -m pytest \
+  webnovel-writer/scripts/data_modules/tests/test_story_contract_schema.py \
+  webnovel-writer/scripts/data_modules/tests/test_runtime_contract_builder.py \
+  webnovel-writer/scripts/data_modules/tests/test_prewrite_validator.py \
+  webnovel-writer/scripts/data_modules/tests/test_context_manager.py \
+  webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py \
+  webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py \
+  webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py \
+  -q --no-cov
+```
+
+Expected: 全部通过
+
+- [ ] **Step 4: 回归 `reference_search.py`**
+
+Run: `python -m pytest webnovel-writer/scripts/tests/test_reference_search.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 5: 最终提交**
+
+```bash
+git add README.md \
+        docs/architecture/story-system-phase2.md \
+        docs/architecture/overview.md \
+        docs/guides/commands.md \
+        docs/superpowers/README.md
+git commit -m "docs: document story system phase2 contract-first runtime"
+```
+
+---
+
+## Spec Coverage Check
+
+- `13.3 Phase 2:合同优先运行时`
+  - `VOLUME_BRIEF`:Task 1 / Task 2
+  - `REVIEW_CONTRACT`:Task 1 / Task 2
+  - 写前禁区与消歧域:Task 3
+  - 大纲履约 diff seed:Task 3
+  - `context_manager` contract-first pack:Task 3 / Task 4
+
+- `7.2 运行时优先级`
+  - `chapter -> volume -> master -> old profile`:Task 3 / Task 4
+
+- `11.1 写前校验`
+  - 可见合同、禁区、消歧 pending、must cover:Task 3
+
+- `17.1 文档更新要求`
+  - schema、目录、流程、命令:Task 5
+
+---
+
+## Placeholder Scan
+
+- 没有使用 `TODO / TBD / implement later`
+- 没有把“接入 skill”写成空话,已给出具体 skill 文件
+- 没有把 Phase 3 的 `CHAPTER_COMMIT` 混入本阶段任务
+
+---
+
+## Next Plan
+
+Phase 2 完成后进入:
+
+1. `Phase 3 Chapter Commit Chain`
+2. `Phase 4 Event Log And Override Ledger`

+ 669 - 0
docs/superpowers/plans/2026-04-12-story-system-phase3-chapter-commit-chain.md

@@ -0,0 +1,669 @@
+# Story System Phase 3 Chapter Commit Chain Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 建立 `CHAPTER_COMMIT` 主链、accepted / rejected 语义与四类 projection writers,让章节事实写后回写统一经过提交对象,而不再散写到 `state / index / summary / memory`。
+
+**Architecture:** 在 Phase 2 的合同优先运行时之上,引入 `CHAPTER_COMMIT.json` 作为写后唯一事实入口。提交阶段先汇总 `review_result / fulfillment_result / disambiguation_result / accepted_events / deltas`,只有 `commit accepted` 才允许投影器分发到下游存储。`state_manager / memory writer / index_manager` 在本阶段重定位为投影写入器底座,而不是章节事实真源。
+
+**Tech Stack:** Python 3.13, Pydantic, argparse, pytest, SQLite (`index.db`), JSON commit artifacts under `.story-system/commits`
+
+**Spec:** `docs/superpowers/specs/2026-04-12-story-system-evolution-spec.md`
+
+**Companion Plans:** `docs/superpowers/plans/2026-04-12-story-system-phase1-contract-seed.md`, `docs/superpowers/plans/2026-04-12-story-system-phase2-contract-first-runtime.md`
+
+---
+
+## Scope Split
+
+本计划只覆盖 Phase 3:
+
+1. `CHAPTER_COMMIT`
+2. 四类 projection writers
+3. accepted / rejected commit 语义
+4. 写后回写改为 commit 驱动
+
+明确不做:
+
+- 不引入 canonical event log 全局主链
+- 不把 override ledger 扩展成完整审计账本
+- 不做旧链路降级收尾
+
+退出标准:
+
+1. `PROJECT_ROOT/.story-system/commits/chapter_XXX.commit.json` 成为写后事实入口
+2. rejected commit 不写下游存储
+3. accepted commit 才触发 `state / index / summary / memory` 投影,其中 `StateProjectionWriter` 必须真实更新 `state.json`
+4. `projection_status` 可追踪每个 writer 的完成情况;写入失败只记录到对应 writer 状态,不回滚 `commit accepted/rejected` 判定
+
+文档更新继续追加到已有 `Story System` 段落,不重写 README 总体结构。
+
+---
+
+## File Structure
+
+### 要创建的文件
+
+- `webnovel-writer/scripts/chapter_commit.py`
+- `webnovel-writer/scripts/data_modules/story_commit_schema.py`
+- `webnovel-writer/scripts/data_modules/chapter_commit_service.py`
+- `webnovel-writer/scripts/data_modules/state_projection_writer.py`
+- `webnovel-writer/scripts/data_modules/index_projection_writer.py`
+- `webnovel-writer/scripts/data_modules/summary_projection_writer.py`
+- `webnovel-writer/scripts/data_modules/memory_projection_writer.py`
+- `webnovel-writer/scripts/data_modules/tests/test_story_commit_schema.py`
+- `webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py`
+- `webnovel-writer/scripts/data_modules/tests/test_projection_writers.py`
+- `docs/architecture/story-system-phase3.md`
+
+### 要修改的文件
+
+- `webnovel-writer/scripts/data_modules/story_contracts.py`
+- `webnovel-writer/scripts/data_modules/webnovel.py`
+- `webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py`
+- `webnovel-writer/scripts/review_pipeline.py`
+- `webnovel-writer/scripts/data_modules/state_manager.py`
+- `webnovel-writer/scripts/data_modules/memory/writer.py`
+- `webnovel-writer/skills/webnovel-write/SKILL.md`
+- `webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py`
+- `README.md`
+- `docs/architecture/overview.md`
+- `docs/guides/commands.md`
+- `docs/superpowers/README.md`
+
+---
+
+## Task 1: 定义 `CHAPTER_COMMIT` schema 与落盘路径
+
+**Files:**
+- Create: `webnovel-writer/scripts/data_modules/story_commit_schema.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_story_commit_schema.py`
+- Modify: `webnovel-writer/scripts/data_modules/story_contracts.py`
+
+- [ ] **Step 1: 先写 schema 测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_story_commit_schema.py
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.story_commit_schema import ChapterCommit
+
+
+def test_chapter_commit_accepts_required_sections():
+    payload = {
+        "meta": {"schema_version": "story-system/v1", "chapter": 3, "status": "accepted"},
+        "contract_refs": {"master": "MASTER_SETTING.json", "chapter": "chapter_003.json"},
+        "outline_snapshot": {"planned_nodes": ["发现陷阱"]},
+        "review_result": {"blocking_count": 0},
+        "fulfillment_result": {"missed_nodes": []},
+        "disambiguation_result": {"pending": []},
+        "accepted_events": [],
+        "state_deltas": [],
+        "entity_deltas": [],
+        "projection_status": {"state": "pending", "index": "pending", "summary": "pending", "memory": "pending"},
+    }
+    model = ChapterCommit.model_validate(payload)
+    assert model.meta["status"] == "accepted"
+```
+
+- [ ] **Step 2: 跑红灯**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_commit_schema.py -q --no-cov`
+
+Expected: `ModuleNotFoundError: No module named 'data_modules.story_commit_schema'`
+
+- [ ] **Step 3: 实现 schema 与 commit 路径**
+
+```python
+# webnovel-writer/scripts/data_modules/story_commit_schema.py
+from __future__ import annotations
+
+from typing import Any, Dict, List
+
+from pydantic import BaseModel, Field
+
+
+class ChapterCommit(BaseModel):
+    meta: Dict[str, Any]
+    contract_refs: Dict[str, str]
+    outline_snapshot: Dict[str, Any]
+    review_result: Dict[str, Any]
+    fulfillment_result: Dict[str, Any]
+    disambiguation_result: Dict[str, Any]
+    accepted_events: List[Dict[str, Any]] = Field(default_factory=list)
+    state_deltas: List[Dict[str, Any]] = Field(default_factory=list)
+    entity_deltas: List[Dict[str, Any]] = Field(default_factory=list)
+    projection_status: Dict[str, str]
+```
+
+```python
+# webnovel-writer/scripts/data_modules/story_contracts.py
+@property
+def commits_dir(self) -> Path:
+    return self.root / "commits"
+
+def commit_json(self, chapter: int) -> Path:
+    return self.commits_dir / f"chapter_{chapter:03d}.commit.json"
+```
+
+- [ ] **Step 4: 回跑测试**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_commit_schema.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add webnovel-writer/scripts/data_modules/story_commit_schema.py \
+        webnovel-writer/scripts/data_modules/story_contracts.py \
+        webnovel-writer/scripts/data_modules/tests/test_story_commit_schema.py
+git commit -m "feat: add chapter commit schema and paths"
+```
+
+---
+
+## Task 2: 实现 `chapter_commit_service` 与提交校验
+
+**Files:**
+- Create: `webnovel-writer/scripts/data_modules/chapter_commit_service.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py`
+- Modify: `webnovel-writer/scripts/review_pipeline.py`
+
+- [ ] **Step 1: 先写提交通过/阻断测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.chapter_commit_service import ChapterCommitService
+
+
+def test_commit_service_rejects_when_missed_nodes_exist(tmp_path):
+    service = ChapterCommitService(tmp_path)
+    payload = service.build_commit(
+        chapter=3,
+        review_result={"blocking_count": 0},
+        fulfillment_result={"planned_nodes": ["发现陷阱"], "missed_nodes": ["发现陷阱"]},
+        disambiguation_result={"pending": []},
+        extraction_result={"state_deltas": [], "entity_deltas": [], "accepted_events": []},
+    )
+    assert payload["meta"]["status"] == "rejected"
+
+
+def test_commit_service_accepts_when_all_checks_pass(tmp_path):
+    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={"state_deltas": [], "entity_deltas": [], "accepted_events": []},
+    )
+    assert payload["meta"]["status"] == "accepted"
+    assert payload["contract_refs"]["master"] == "MASTER_SETTING.json"
+    assert payload["contract_refs"]["chapter"] == "chapter_003.json"
+    assert payload["outline_snapshot"]["covered_nodes"] == ["发现陷阱"]
+```
+
+- [ ] **Step 2: 跑红灯**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py -q --no-cov`
+
+Expected: `ModuleNotFoundError: No module named 'data_modules.chapter_commit_service'`
+
+- [ ] **Step 3: 实现提交服务**
+
+```python
+# webnovel-writer/scripts/data_modules/chapter_commit_service.py
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any, Dict
+
+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
+
+
+class ChapterCommitService:
+    def __init__(self, project_root: Path):
+        self.project_root = Path(project_root)
+
+    def build_commit(
+        self,
+        chapter: int,
+        review_result: Dict[str, Any],
+        fulfillment_result: Dict[str, Any],
+        disambiguation_result: Dict[str, Any],
+        extraction_result: Dict[str, Any],
+    ) -> Dict[str, Any]:
+        rejected = bool(review_result.get("blocking_count")) or bool(fulfillment_result.get("missed_nodes")) or bool(disambiguation_result.get("pending"))
+        status = "rejected" if rejected else "accepted"
+        return {
+            "meta": {"schema_version": "story-system/v1", "chapter": chapter, "status": status},
+            "contract_refs": {
+                "master": "MASTER_SETTING.json",
+                "chapter": f"chapter_{chapter:03d}.json",
+                "review": f"chapter_{chapter:03d}.review.json",
+            },
+            "outline_snapshot": {
+                "planned_nodes": fulfillment_result.get("planned_nodes", []),
+                "covered_nodes": fulfillment_result.get("covered_nodes", []),
+                "missed_nodes": fulfillment_result.get("missed_nodes", []),
+                "extra_nodes": fulfillment_result.get("extra_nodes", []),
+            },
+            "review_result": review_result,
+            "fulfillment_result": fulfillment_result,
+            "disambiguation_result": disambiguation_result,
+            "accepted_events": extraction_result.get("accepted_events", []),
+            "state_deltas": extraction_result.get("state_deltas", []),
+            "entity_deltas": extraction_result.get("entity_deltas", []),
+            "projection_status": {"state": "pending", "index": "pending", "summary": "pending", "memory": "pending"},
+        }
+
+    def persist_commit(self, payload: Dict[str, Any]) -> Path:
+        target = self.project_root / ".story-system" / "commits"
+        target.mkdir(parents=True, exist_ok=True)
+        path = target / f"chapter_{int(payload['meta']['chapter']):03d}.commit.json"
+        path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+        return path
+
+    def apply_projections(self, payload: Dict[str, Any]) -> Dict[str, Any]:
+        if payload["meta"]["status"] != "accepted":
+            return payload
+
+        writers = {
+            "state": StateProjectionWriter(self.project_root),
+            "index": IndexProjectionWriter(self.project_root),
+            "summary": SummaryProjectionWriter(self.project_root),
+            "memory": MemoryProjectionWriter(self.project_root),
+        }
+        for name, writer in writers.items():
+            try:
+                result = writer.apply(payload)
+                payload["projection_status"][name] = "done" if result.get("applied") else "skipped"
+            except Exception as exc:
+                payload["projection_status"][name] = f"failed:{exc}"
+        self.persist_commit(payload)
+        return payload
+```
+
+这里补一条 Phase 3 / Phase 4 的职责协议,后续实现必须遵守:
+
+- `ChapterCommitService.apply_projections()` 始终是唯一调度入口
+- Phase 4 引入的 `EventProjectionRouter` 只负责判定“哪些 writer 应被激活”
+- `EventProjectionRouter` **不单独再跑一轮投影**,避免 `state_deltas` 与 `accepted_events` 双重落库
+
+`review_pipeline.py` 在本 Task 必须补一条明确接线:
+
+- 汇总 `review_result / fulfillment_result / disambiguation_result / extraction_result`
+- 调 `ChapterCommitService.build_commit()`
+- 先 `persist_commit()`,再依据 `payload["meta"]["status"]` 决定是否进入投影阶段
+
+也就是说,`review_pipeline.py` 不再只是“被列进修改文件”,而是 Phase 3 写后主链真正的调用入口。
+
+- [ ] **Step 4: 回跑测试**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add webnovel-writer/scripts/data_modules/chapter_commit_service.py \
+        webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py \
+        webnovel-writer/scripts/review_pipeline.py
+git commit -m "feat: add chapter commit service and status semantics"
+```
+
+---
+
+## Task 3: 落地四类 projection writers
+
+**Files:**
+- Create: `webnovel-writer/scripts/data_modules/state_projection_writer.py`
+- Create: `webnovel-writer/scripts/data_modules/index_projection_writer.py`
+- Create: `webnovel-writer/scripts/data_modules/summary_projection_writer.py`
+- Create: `webnovel-writer/scripts/data_modules/memory_projection_writer.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_projection_writers.py`
+- Modify: `webnovel-writer/scripts/data_modules/index_manager.py`
+- Modify: `webnovel-writer/scripts/data_modules/state_manager.py`
+- Modify: `webnovel-writer/scripts/data_modules/memory/writer.py`
+
+- [ ] **Step 1: 先写 accepted / rejected 投影测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_projection_writers.py
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import json
+
+from data_modules.chapter_commit_service import ChapterCommitService
+from data_modules.state_projection_writer import StateProjectionWriter
+
+
+def test_state_projection_writer_skips_rejected_commit(tmp_path):
+    writer = StateProjectionWriter(tmp_path)
+    result = writer.apply({"meta": {"status": "rejected"}, "state_deltas": []})
+    assert result["applied"] is False
+
+
+def test_state_projection_writer_applies_accepted_commit(tmp_path):
+    (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    writer = StateProjectionWriter(tmp_path)
+    result = writer.apply({"meta": {"status": "accepted"}, "state_deltas": [{"entity_id": "x", "field": "realm", "new": "斗者"}]})
+    assert result["applied"] is True
+    payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
+    assert payload["entity_state"]["x"]["realm"] == "斗者"
+
+
+def test_accepted_commit_updates_state_json_end_to_end(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)
+    commit_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={"state_deltas": [{"entity_id": "x", "field": "realm", "new": "斗者"}], "entity_deltas": [], "accepted_events": []},
+    )
+
+    StateProjectionWriter(tmp_path).apply(commit_payload)
+    payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
+    assert payload["entity_state"]["x"]["realm"] == "斗者"
+```
+
+- [ ] **Step 2: 跑红灯**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_projection_writers.py -q --no-cov`
+
+Expected: `ModuleNotFoundError` for projection writer modules
+
+- [ ] **Step 3: 实现四类 writer**
+
+```python
+# webnovel-writer/scripts/data_modules/state_projection_writer.py
+from data_modules.story_contracts import read_json_if_exists
+
+
+class StateProjectionWriter:
+    def __init__(self, project_root: Path):
+        self.project_root = Path(project_root)
+
+    def apply(self, commit_payload: dict) -> dict:
+        if commit_payload["meta"]["status"] != "accepted":
+            return {"applied": False, "writer": "state", "reason": "commit_rejected"}
+
+        state_path = self.project_root / ".webnovel" / "state.json"
+        state = read_json_if_exists(state_path) or {}
+        entity_state = state.setdefault("entity_state", {})
+        applied_count = 0
+        for delta in commit_payload.get("state_deltas", []):
+            entity_id = str(delta.get("entity_id") or "").strip()
+            field = str(delta.get("field") or "").strip()
+            if not entity_id or not field:
+                continue
+            entity_state.setdefault(entity_id, {})[field] = delta.get("new")
+            applied_count += 1
+
+        state_path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
+        return {"applied": applied_count > 0, "writer": "state", "applied_count": applied_count}
+```
+
+其他三个 writer 在 Phase 3 可以先保持“最小投影”,但**不能是 no-op stub**,至少要薄适配到现有底座:
+
+```python
+class IndexProjectionWriter:
+    def apply(self, commit_payload: dict) -> dict:
+        if commit_payload["meta"]["status"] != "accepted":
+            return {"applied": False, "writer": "index", "reason": "commit_rejected"}
+        manager = IndexManager(self.project_root)
+        for delta in commit_payload.get("entity_deltas", []):
+            manager.apply_entity_delta(delta)
+        return {"applied": True, "writer": "index", "applied_count": len(commit_payload.get("entity_deltas", []))}
+
+
+class SummaryProjectionWriter:
+    def apply(self, commit_payload: dict) -> dict:
+        if commit_payload["meta"]["status"] != "accepted":
+            return {"applied": False, "writer": "summary", "reason": "commit_rejected"}
+        return append_summary_projection(self.project_root, commit_payload)
+
+
+class MemoryProjectionWriter:
+    def apply(self, commit_payload: dict) -> dict:
+        if commit_payload["meta"]["status"] != "accepted":
+            return {"applied": False, "writer": "memory", "reason": "commit_rejected"}
+        return MemoryWriter(self.project_root).apply_commit_projection(commit_payload)
+```
+
+这里的交付要求写死:
+
+- `StateProjectionWriter` 必须真实落地
+- `Index / Summary / Memory` 允许是薄适配,但必须调用真实底座或真实文件写入
+- 如果仓库当前不存在 `IndexManager.apply_entity_delta()`、`append_summary_projection()`、`MemoryWriter.apply_commit_projection()`,就在本 Task 一并补最小适配器骨架;函数名可调整,但 writer 层对外协议不变
+- `projection_status` 记录 `"done"` / `"skipped"` / `"failed:..."`,不能一律回 `"pending"`
+
+- [ ] **Step 4: 回跑测试**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_projection_writers.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add webnovel-writer/scripts/data_modules/state_projection_writer.py \
+        webnovel-writer/scripts/data_modules/index_projection_writer.py \
+        webnovel-writer/scripts/data_modules/summary_projection_writer.py \
+        webnovel-writer/scripts/data_modules/memory_projection_writer.py \
+        webnovel-writer/scripts/data_modules/tests/test_projection_writers.py \
+        webnovel-writer/scripts/data_modules/state_manager.py \
+        webnovel-writer/scripts/data_modules/memory/writer.py
+git commit -m "feat: add commit-driven projection writers"
+```
+
+---
+
+## Task 4: CLI / Skill 接入、文档与验证
+
+**Files:**
+- Create: `webnovel-writer/scripts/chapter_commit.py`
+- Modify: `webnovel-writer/scripts/data_modules/webnovel.py`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py`
+- Modify: `webnovel-writer/skills/webnovel-write/SKILL.md`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py`
+- Create: `docs/architecture/story-system-phase3.md`
+- Modify: `README.md`
+- Modify: `docs/architecture/overview.md`
+- Modify: `docs/guides/commands.md`
+- Modify: `docs/superpowers/README.md`
+
+- [ ] **Step 1: 增加统一 CLI 转发测试**
+
+```python
+def test_webnovel_commit_forwards(monkeypatch, tmp_path):
+    from data_modules import webnovel as cli
+    project_root = tmp_path / "book"
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    called = {}
+
+    def _fake_run_script(script_name, argv):
+        called["script_name"] = script_name
+        called["argv"] = argv
+        return 0
+
+    monkeypatch.setattr(cli, "_run_script", _fake_run_script)
+    monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "chapter-commit", "--chapter", "3"])
+    cli.main()
+
+    assert called["script_name"] == "chapter_commit.py"
+
+
+def test_chapter_commit_cli_builds_and_persists_commit(tmp_path, monkeypatch):
+    review_path = tmp_path / "review.json"
+    fulfillment_path = tmp_path / "fulfillment.json"
+    disambiguation_path = tmp_path / "disambiguation.json"
+    extraction_path = tmp_path / "extraction.json"
+    review_path.write_text('{"blocking_count": 0}', encoding="utf-8")
+    fulfillment_path.write_text('{"planned_nodes": ["发现陷阱"], "covered_nodes": ["发现陷阱"], "missed_nodes": [], "extra_nodes": []}', encoding="utf-8")
+    disambiguation_path.write_text('{"pending": []}', encoding="utf-8")
+    extraction_path.write_text('{"state_deltas": [], "entity_deltas": [], "accepted_events": []}', encoding="utf-8")
+
+    from chapter_commit import main
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "chapter_commit",
+            "--project-root",
+            str(tmp_path),
+            "--chapter",
+            "3",
+            "--review-result",
+            str(review_path),
+            "--fulfillment-result",
+            str(fulfillment_path),
+            "--disambiguation-result",
+            str(disambiguation_path),
+            "--extraction-result",
+            str(extraction_path),
+        ],
+    )
+    main()
+
+    assert (tmp_path / ".story-system" / "commits" / "chapter_003.commit.json").is_file()
+```
+
+- [ ] **Step 2: 接入 CLI 与技能**
+
+在 `webnovel.py` 增加:
+
+```python
+# webnovel-writer/scripts/chapter_commit.py
+def _read_json(path: str) -> dict:
+    return json.loads(Path(path).read_text(encoding="utf-8"))
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Chapter commit CLI")
+    parser.add_argument("--project-root", required=True)
+    parser.add_argument("--chapter", type=int, required=True)
+    parser.add_argument("--review-result", required=True)
+    parser.add_argument("--fulfillment-result", required=True)
+    parser.add_argument("--disambiguation-result", required=True)
+    parser.add_argument("--extraction-result", required=True)
+    args = parser.parse_args()
+
+    service = ChapterCommitService(Path(args.project_root))
+    payload = service.build_commit(
+        chapter=args.chapter,
+        review_result=_read_json(args.review_result),
+        fulfillment_result=_read_json(args.fulfillment_result),
+        disambiguation_result=_read_json(args.disambiguation_result),
+        extraction_result=_read_json(args.extraction_result),
+    )
+    service.persist_commit(payload)
+    if payload["meta"]["status"] == "accepted":
+        payload = service.apply_projections(payload)
+    print(json.dumps(payload, ensure_ascii=False))
+
+# webnovel-writer/scripts/data_modules/webnovel.py
+p_commit = sub.add_parser("chapter-commit", help="转发到 chapter_commit.py")
+p_commit.add_argument("args", nargs=argparse.REMAINDER)
+```
+
+在 `skills/webnovel-write/SKILL.md` 将原先“写完直接 state / index / summaries / memory 回写”替换为:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" \
+  chapter-commit --chapter {chapter_num} \
+  --review-result "{review_json}" \
+  --fulfillment-result "{fulfillment_json}" \
+  --disambiguation-result "{disambiguation_json}" \
+  --extraction-result "{extraction_json}"
+```
+
+同时在文档里明确一个运行约束:
+
+- `chapter_commit.py` 是独立人工/CLI 入口
+- `review_pipeline.py` 是 skill 主流程中的集成入口
+- 同一次写后流程只能走其中一个入口,禁止 `review_pipeline.py` 已提交后再补跑 `chapter_commit.py`
+
+- [ ] **Step 3: 新建 Phase 3 文档并跑回归**
+
+Run:
+
+```bash
+python -m pytest \
+  webnovel-writer/scripts/data_modules/tests/test_story_commit_schema.py \
+  webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py \
+  webnovel-writer/scripts/data_modules/tests/test_projection_writers.py \
+  webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py \
+  webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py \
+  -q --no-cov
+```
+
+Expected: 全部通过
+
+- [ ] **Step 4: 最终提交**
+
+```bash
+git add webnovel-writer/scripts/chapter_commit.py \
+        webnovel-writer/scripts/data_modules/webnovel.py \
+        webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py \
+        webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py \
+        webnovel-writer/skills/webnovel-write/SKILL.md \
+        webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py \
+        README.md \
+        docs/architecture/story-system-phase3.md \
+        docs/architecture/overview.md \
+        docs/guides/commands.md \
+        docs/superpowers/README.md
+git commit -m "docs: document story system phase3 chapter commit chain"
+```
+
+---
+
+## Spec Coverage Check
+
+- `13.4 Phase 3:章节提交主链`
+  - `CHAPTER_COMMIT`:Task 1 / Task 2
+  - 四类 projection writers:Task 3
+  - accepted / rejected 语义:Task 2 / Task 3
+  - 写后回写改为 commit 驱动:Task 3 / Task 4
+
+- `9.2 / 9.3 / 9.5`
+  - 最小结构、提交流程、失败语义:Task 1 / Task 2
+
+- `11.2 / 11.3`
+  - 履约 / missed nodes 阻断:Task 2
+
+---
+
+## Placeholder Scan
+
+- 没有使用 `TODO / TBD`
+- 没有把 projection writer 写成“后续补齐”
+- 没有提前把 Phase 4 的 canonical event log 混进本阶段
+
+---
+
+## Next Plan
+
+Phase 3 完成后进入:
+
+1. `Phase 4 Event Log And Override Ledger`

+ 757 - 0
docs/superpowers/plans/2026-04-12-story-system-phase4-event-log-and-override-ledger.md

@@ -0,0 +1,757 @@
+# Story System Phase 4 Event Log And Override Ledger Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 建立 canonical event log、事件到投影的稳定映射、事件到 `amend proposal` 的触发规则,并把现有 `override_contracts` 演进成统一 override ledger 的底座。
+
+**Architecture:** 在 Phase 3 的 `CHAPTER_COMMIT.accepted_events` 基础上,新增 `.story-system/events/` 持久化与 `index.db` 审计镜像,让事件成为正式输入而不是散落在 `state_changes / relationship_events / memory_facts` 的局部痕迹。同时把 `override_contracts` 从追读力债务专用扩展成包含 `soft_deviation / contract_override / amend_proposal` 的统一账本,但默认 runtime 只消费当前章相关摘要,不整包注入 prompt。
+
+**Tech Stack:** Python 3.13, Pydantic, pytest, SQLite (`index.db`), JSON event artifacts, dashboard / observability hooks
+
+**Spec:** `docs/superpowers/specs/2026-04-12-story-system-evolution-spec.md`
+
+**Companion Plans:** `docs/superpowers/plans/2026-04-12-story-system-phase3-chapter-commit-chain.md`, `docs/superpowers/specs/2026-04-12-webnovel-story-intelligence-system-spec.md`
+
+---
+
+## Scope Split
+
+本计划只覆盖 Phase 4:
+
+1. canonical event log
+2. 事件到投影的稳定映射
+3. 事件到 `amend proposal` 的触发规则
+4. override ledger 扩展
+
+明确不做:
+
+- 不做 Phase 5 的旧链路降级
+- 不清理 `genre-profiles.md` 回退链
+- 不把所有历史 override 直接注入 runtime prompt
+
+退出标准:
+
+1. accepted commit 会稳定产出事件文件,并同步写入 `.webnovel/index.db.story_events`
+2. 投影层优先消费事件而不是最终覆盖值
+3. 需要上提的设定变更会生成 `amend proposal`
+4. `override_contracts` 可承载三类记录,并保留兼容旧追读力债务数据
+5. dashboard / preflight / health / backup 至少具备最小接入说明和只读检查入口
+
+文档更新继续追加到已有 `Story System` 段落,不重写 README 总体结构。
+
+---
+
+## File Structure
+
+### 要创建的文件
+
+- `webnovel-writer/scripts/story_events.py`
+- `webnovel-writer/scripts/data_modules/story_event_schema.py`
+- `webnovel-writer/scripts/data_modules/event_log_store.py`
+- `webnovel-writer/scripts/data_modules/event_projection_router.py`
+- `webnovel-writer/scripts/data_modules/amend_proposal_schema.py`
+- `webnovel-writer/scripts/data_modules/override_ledger_service.py`
+- `webnovel-writer/scripts/data_modules/tests/test_story_event_schema.py`
+- `webnovel-writer/scripts/data_modules/tests/test_event_log_store.py`
+- `webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py`
+- `webnovel-writer/scripts/data_modules/tests/test_override_ledger_service.py`
+- `docs/architecture/story-system-phase4.md`
+
+### 要修改的文件
+
+- `webnovel-writer/scripts/data_modules/chapter_commit_service.py`
+- `webnovel-writer/scripts/data_modules/index_manager.py`
+- `webnovel-writer/scripts/data_modules/index_debt_mixin.py`
+- `webnovel-writer/scripts/data_modules/index_observability_mixin.py`
+- `webnovel-writer/scripts/data_modules/webnovel.py`
+- `webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py`
+- `webnovel-writer/dashboard/app.py`
+- `README.md`
+- `docs/architecture/overview.md`
+- `docs/guides/commands.md`
+- `docs/operations/operations.md`
+- `docs/superpowers/README.md`
+
+---
+
+## Task 1: 定义事件 schema 与 canonical event 持久化
+
+**Files:**
+- Create: `webnovel-writer/scripts/data_modules/story_event_schema.py`
+- Create: `webnovel-writer/scripts/data_modules/event_log_store.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_story_event_schema.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_event_log_store.py`
+- Modify: `webnovel-writer/scripts/data_modules/chapter_commit_service.py`
+
+- [ ] **Step 1: 先写事件 schema / store 测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_story_event_schema.py
+from data_modules.story_event_schema import StoryEvent
+
+
+def test_story_event_supports_power_breakthrough():
+    event = StoryEvent.model_validate(
+        {
+            "event_id": "evt-001",
+            "chapter": 3,
+            "event_type": "power_breakthrough",
+            "subject": "xiaoyan",
+            "payload": {"from": "斗之气三段", "to": "斗者"},
+        }
+    )
+    assert event.event_type == "power_breakthrough"
+```
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_event_log_store.py
+import sqlite3
+
+from data_modules.event_log_store import EventLogStore
+
+
+def test_event_log_store_writes_per_chapter_file_and_sqlite_mirror(tmp_path):
+    store = EventLogStore(tmp_path)
+    store.write_events(3, [{"event_id": "evt-001", "event_type": "open_loop_created", "subject": "三年之约", "payload": {}}])
+    assert (tmp_path / ".story-system" / "events" / "chapter_003.events.json").is_file()
+
+    conn = sqlite3.connect(tmp_path / ".webnovel" / "index.db")
+    try:
+        row = conn.execute("SELECT event_id, chapter, event_type FROM story_events").fetchone()
+    finally:
+        conn.close()
+    assert row == ("evt-001", 3, "open_loop_created")
+```
+
+- [ ] **Step 2: 跑红灯**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_event_schema.py webnovel-writer/scripts/data_modules/tests/test_event_log_store.py -q --no-cov`
+
+Expected: `ModuleNotFoundError`
+
+- [ ] **Step 3: 实现 schema 与 store**
+
+```python
+# webnovel-writer/scripts/data_modules/story_event_schema.py
+from __future__ import annotations
+
+from typing import Any, Dict, Literal
+
+from pydantic import BaseModel
+
+
+class StoryEvent(BaseModel):
+    event_id: str
+    chapter: int
+    event_type: Literal[
+        "character_state_changed",
+        "relationship_changed",
+        "world_rule_revealed",
+        "world_rule_broken",
+        "power_breakthrough",
+        "artifact_obtained",
+        "promise_created",
+        "promise_paid_off",
+        "open_loop_created",
+        "open_loop_closed",
+    ]
+    subject: str
+    payload: Dict[str, Any]
+```
+
+```python
+# webnovel-writer/scripts/data_modules/event_log_store.py
+import json
+import sqlite3
+from pathlib import Path
+
+
+class EventLogStore:
+    def __init__(self, project_root: Path):
+        self.project_root = Path(project_root)
+
+    def write_events(self, chapter: int, events: list[dict]) -> Path:
+        target = self.project_root / ".story-system" / "events"
+        target.mkdir(parents=True, exist_ok=True)
+        path = target / f"chapter_{chapter:03d}.events.json"
+        path.write_text(json.dumps(events, ensure_ascii=False, indent=2), encoding="utf-8")
+        self._write_sqlite_mirror(chapter, events)
+        return path
+
+    def _write_sqlite_mirror(self, chapter: int, events: list[dict]) -> None:
+        db_path = self.project_root / ".webnovel" / "index.db"
+        db_path.parent.mkdir(parents=True, exist_ok=True)
+        conn = sqlite3.connect(db_path)
+        try:
+            conn.execute(
+                """
+                CREATE TABLE IF NOT EXISTS story_events (
+                    id INTEGER PRIMARY KEY AUTOINCREMENT,
+                    event_id TEXT NOT NULL UNIQUE,
+                    chapter INTEGER NOT NULL,
+                    event_type TEXT NOT NULL,
+                    subject TEXT NOT NULL,
+                    payload_json TEXT NOT NULL
+                )
+                """
+            )
+            conn.executemany(
+                "INSERT OR IGNORE INTO story_events(event_id, chapter, event_type, subject, payload_json) VALUES (?, ?, ?, ?, ?)",
+                [
+                    (
+                        event["event_id"],
+                        chapter,
+                        event["event_type"],
+                        event["subject"],
+                        json.dumps(event.get("payload") or {}, ensure_ascii=False),
+                    )
+                    for event in events
+                ],
+            )
+            conn.commit()
+        finally:
+            conn.close()
+```
+
+- [ ] **Step 4: 让 `chapter_commit_service` 在 accepted commit 后写事件文件**
+
+```python
+if payload["meta"]["status"] == "accepted":
+    EventLogStore(self.project_root).write_events(chapter, payload["accepted_events"])
+```
+
+- [ ] **Step 5: 回跑测试**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_story_event_schema.py webnovel-writer/scripts/data_modules/tests/test_event_log_store.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add webnovel-writer/scripts/data_modules/story_event_schema.py \
+        webnovel-writer/scripts/data_modules/event_log_store.py \
+        webnovel-writer/scripts/data_modules/tests/test_story_event_schema.py \
+        webnovel-writer/scripts/data_modules/tests/test_event_log_store.py \
+        webnovel-writer/scripts/data_modules/chapter_commit_service.py
+git commit -m "feat: add canonical event schema and per-chapter event store"
+```
+
+---
+
+## Task 2: 建立事件到投影的稳定映射
+
+**Files:**
+- Create: `webnovel-writer/scripts/data_modules/event_projection_router.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py`
+- Modify: `webnovel-writer/scripts/data_modules/state_projection_writer.py`
+- Modify: `webnovel-writer/scripts/data_modules/index_projection_writer.py`
+- Modify: `webnovel-writer/scripts/data_modules/memory_projection_writer.py`
+
+- [ ] **Step 1: 先写事件路由测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py
+from data_modules.event_projection_router import EventProjectionRouter
+
+
+def test_router_maps_power_breakthrough_to_state_and_memory():
+    router = EventProjectionRouter()
+    targets = router.route({"event_type": "power_breakthrough", "subject": "xiaoyan", "payload": {}})
+    assert targets == ["state", "memory"]
+
+
+def test_router_maps_relationship_changed_to_index():
+    router = EventProjectionRouter()
+    targets = router.route({"event_type": "relationship_changed", "subject": "xiaoyan", "payload": {"to": "yaolao"}})
+    assert "index" in targets
+```
+
+- [ ] **Step 2: 跑红灯**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py -q --no-cov`
+
+Expected: `ModuleNotFoundError`
+
+- [ ] **Step 3: 实现路由器并让 writer 消费事件**
+
+```python
+# webnovel-writer/scripts/data_modules/event_projection_router.py
+class EventProjectionRouter:
+    TABLE = {
+        "character_state_changed": ["state", "memory"],
+        "power_breakthrough": ["state", "memory"],
+        "relationship_changed": ["index"],
+        "world_rule_revealed": ["memory", "index"],
+        "world_rule_broken": ["memory", "index"],
+        "open_loop_created": ["memory"],
+        "open_loop_closed": ["memory"],
+        "promise_created": ["memory"],
+        "promise_paid_off": ["memory"],
+        "artifact_obtained": ["state", "index"],
+    }
+
+    def route(self, event: dict) -> list[str]:
+        return list(self.TABLE.get(event.get("event_type"), []))
+```
+
+这里把 P3 / P4 的关系写死,避免实现时出现双重投影:
+
+- `EventProjectionRouter` 是**声明式激活表**
+- 真正的执行入口仍是 `ChapterCommitService.apply_projections()`
+- `apply_projections()` 先汇总 `accepted_events` 命中的 writer 集合,再只调需要的 writer
+- Phase 4 **不新增第二套独立投影循环**
+
+- [ ] **Step 4: 回跑测试**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add webnovel-writer/scripts/data_modules/event_projection_router.py \
+        webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py \
+        webnovel-writer/scripts/data_modules/state_projection_writer.py \
+        webnovel-writer/scripts/data_modules/index_projection_writer.py \
+        webnovel-writer/scripts/data_modules/memory_projection_writer.py
+git commit -m "feat: route accepted events into projection writers"
+```
+
+---
+
+## Task 3: 把 `override_contracts` 扩展为统一 override ledger
+
+**Files:**
+- Create: `webnovel-writer/scripts/data_modules/amend_proposal_schema.py`
+- Create: `webnovel-writer/scripts/data_modules/override_ledger_service.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_override_ledger_service.py`
+- Modify: `webnovel-writer/scripts/data_modules/index_manager.py`
+- Modify: `webnovel-writer/scripts/data_modules/index_debt_mixin.py`
+- Modify: `webnovel-writer/scripts/data_modules/index_observability_mixin.py`
+
+- [ ] **Step 1: 先写 ledger / amend proposal 测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_override_ledger_service.py
+from data_modules.override_ledger_service import AmendProposalTrigger, normalize_override_record
+
+
+def test_normalize_override_record_sets_record_type():
+    row = normalize_override_record(
+        record_type="contract_override",
+        field="core_tone",
+        base_value="先压后爆",
+        override_value="当场爆发",
+        source_level="chapter",
+    )
+    assert row["record_type"] == "contract_override"
+    assert row["field"] == "core_tone"
+
+
+def test_normalize_override_record_supports_amend_proposal():
+    row = normalize_override_record(
+        record_type="amend_proposal",
+        field="world_rule",
+        base_value="金手指每日一次",
+        override_value="金手指失控突破",
+        source_level="master",
+    )
+    assert row["record_type"] == "amend_proposal"
+
+
+def test_world_rule_broken_generates_amend_proposal():
+    trigger = AmendProposalTrigger()
+    proposals = trigger.check(
+        chapter=3,
+        events=[
+            {
+                "event_id": "evt-001",
+                "event_type": "world_rule_broken",
+                "subject": "金手指",
+                "payload": {"field": "world_rule", "base_value": "每日一次", "proposed_value": "短时失控突破"},
+            }
+        ],
+    )
+    assert len(proposals) == 1
+    assert proposals[0]["target_level"] == "master"
+    assert proposals[0]["field"] == "world_rule"
+```
+
+- [ ] **Step 2: 跑红灯**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_override_ledger_service.py -q --no-cov`
+
+Expected: `ModuleNotFoundError`
+
+- [ ] **Step 3: 实现 ledger 标准化与增量迁移**
+
+```python
+# webnovel-writer/scripts/data_modules/amend_proposal_schema.py
+from pydantic import BaseModel
+
+
+class AmendProposal(BaseModel):
+    proposal_id: str
+    chapter: int
+    target_level: str
+    field: str
+    base_value: str
+    proposed_value: str
+    reason_tag: str
+
+# webnovel-writer/scripts/data_modules/override_ledger_service.py
+def normalize_override_record(*, record_type: str, field: str, base_value: str, override_value: str, source_level: str) -> dict:
+    return {
+        "record_type": record_type,
+        "field": field,
+        "base_value": base_value,
+        "override_value": override_value,
+        "source_level": source_level,
+    }
+
+
+class AmendProposalTrigger:
+    RULES = {
+        "world_rule_broken": {"target_level": "master", "reason_tag": "world_rule_broken"},
+        "relationship_changed": None,
+        "power_breakthrough": None,
+        "artifact_obtained": None,
+    }
+
+    def check(self, chapter: int, events: list[dict]) -> list[dict]:
+        proposals: list[dict] = []
+        for event in events:
+            rule = self.RULES.get(event.get("event_type"))
+            if not rule:
+                continue
+            payload = event.get("payload") or {}
+            proposals.append(
+                {
+                    "proposal_id": f"amend-{chapter}-{event.get('event_id')}",
+                    "chapter": chapter,
+                    "target_level": rule["target_level"],
+                    "field": payload.get("field", ""),
+                    "base_value": payload.get("base_value", ""),
+                    "proposed_value": payload.get("proposed_value", ""),
+                    "reason_tag": rule["reason_tag"],
+                }
+            )
+        return proposals
+
+
+def persist_amend_proposals(conn, chapter: int, proposals: list[dict]) -> int:
+    inserted = 0
+    for proposal in proposals:
+        row = normalize_override_record(
+            record_type="amend_proposal",
+            field=proposal["field"],
+            base_value=proposal["base_value"],
+            override_value=proposal["proposed_value"],
+            source_level=proposal["target_level"],
+        )
+        conn.execute(
+            """
+            INSERT INTO override_contracts (
+                chapter,
+                record_type,
+                field,
+                base_value,
+                override_value,
+                source_level,
+                reason_tag,
+                rationale_type,
+                rationale_text,
+                status
+            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            """,
+            (
+                chapter,
+                row["record_type"],
+                row["field"],
+                row["base_value"],
+                row["override_value"],
+                row["source_level"],
+                proposal["reason_tag"],
+                "story_amend_proposal",
+                f"事件触发合同修订提案: {proposal['proposal_id']}",
+                "pending",
+            ),
+        )
+        inserted += 1
+    return inserted
+```
+
+在 `index_manager.py` 对 `override_contracts` 做兼容式扩列:
+
+```python
+def ensure_override_ledger_columns(conn) -> None:
+    existing = {row[1] for row in conn.execute("PRAGMA table_info(override_contracts)").fetchall()}
+    wanted = {
+        "record_type": "TEXT DEFAULT 'soft_deviation'",
+        "field": "TEXT DEFAULT ''",
+        "base_value": "TEXT DEFAULT ''",
+        "override_value": "TEXT DEFAULT ''",
+        "source_level": "TEXT DEFAULT ''",
+        "reason_tag": "TEXT DEFAULT ''",
+    }
+    for name, ddl in wanted.items():
+        if name not in existing:
+            conn.execute(f"ALTER TABLE override_contracts ADD COLUMN {name} {ddl}")
+```
+
+同时在 `chapter_commit_service.py` 的 accepted 分支里补一条完整调用:
+
+```python
+if payload["meta"]["status"] == "accepted":
+    proposals = AmendProposalTrigger().check(chapter, payload["accepted_events"])
+    if proposals:
+        with IndexManager(self.project_root)._get_conn() as conn:
+            ensure_override_ledger_columns(conn)
+            persist_amend_proposals(conn, chapter, proposals)
+            conn.commit()
+```
+
+这样 Phase 4 才算真正实现了“事件 -> amend proposal -> 人工确认后上提合同”的中间主链。
+
+- [ ] **Step 4: 回跑测试**
+
+Run: `python -m pytest webnovel-writer/scripts/data_modules/tests/test_override_ledger_service.py webnovel-writer/scripts/data_modules/tests/test_data_modules.py -q --no-cov`
+
+Expected: 通过
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add webnovel-writer/scripts/data_modules/amend_proposal_schema.py \
+        webnovel-writer/scripts/data_modules/override_ledger_service.py \
+        webnovel-writer/scripts/data_modules/tests/test_override_ledger_service.py \
+        webnovel-writer/scripts/data_modules/index_manager.py \
+        webnovel-writer/scripts/data_modules/index_debt_mixin.py \
+        webnovel-writer/scripts/data_modules/index_observability_mixin.py
+git commit -m "feat: extend override contracts into story override ledger"
+```
+
+---
+
+## Task 4: CLI / Dashboard / 文档与验证
+
+**Files:**
+- Create: `webnovel-writer/scripts/story_events.py`
+- Modify: `webnovel-writer/scripts/data_modules/webnovel.py`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_event_log_store.py`
+- Modify: `webnovel-writer/dashboard/app.py`
+- Create: `docs/architecture/story-system-phase4.md`
+- Modify: `README.md`
+- Modify: `docs/architecture/overview.md`
+- Modify: `docs/guides/commands.md`
+- Modify: `docs/operations/operations.md`
+- Modify: `docs/superpowers/README.md`
+
+- [ ] **Step 1: 增加 CLI 转发与读取测试**
+
+```python
+def test_webnovel_story_events_forwards(monkeypatch, tmp_path):
+    from data_modules import webnovel as cli
+    project_root = tmp_path / "book"
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    called = {}
+
+    def _fake_run_script(script_name, argv):
+        called["script_name"] = script_name
+        called["argv"] = argv
+        return 0
+
+    monkeypatch.setattr(cli, "_run_script", _fake_run_script)
+    monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "story-events", "--chapter", "3"])
+    cli.main()
+    assert called["script_name"] == "story_events.py"
+```
+
+在 `test_event_log_store.py` 追加一个直接读取测试:
+
+```python
+def test_story_events_cli_reads_chapter_file(tmp_path, monkeypatch, capsys):
+    events_dir = tmp_path / ".story-system" / "events"
+    events_dir.mkdir(parents=True, exist_ok=True)
+    (events_dir / "chapter_003.events.json").write_text(
+        '[{"event_id":"evt-001","chapter":3,"event_type":"open_loop_created","subject":"三年之约","payload":{}}]',
+        encoding="utf-8",
+    )
+
+    from story_events import main
+
+    monkeypatch.setattr(sys, "argv", ["story_events", "--project-root", str(tmp_path), "--chapter", "3"])
+    main()
+
+    out = capsys.readouterr().out
+    assert "open_loop_created" in out
+```
+
+- [ ] **Step 2: 暴露查询入口并更新 dashboard**
+
+在 `webnovel.py` 增加:
+
+```python
+# webnovel-writer/scripts/story_events.py
+import json
+import sqlite3
+from pathlib import Path
+
+
+def _events_file(project_root: Path, chapter: int) -> Path:
+    return project_root / ".story-system" / "events" / f"chapter_{chapter:03d}.events.json"
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Story events CLI")
+    parser.add_argument("--project-root", required=True)
+    parser.add_argument("--chapter", type=int, default=0)
+    parser.add_argument("--health", action="store_true")
+    args = parser.parse_args()
+    project_root = Path(args.project_root)
+
+    if args.health:
+        db_path = project_root / ".webnovel" / "index.db"
+        conn = sqlite3.connect(db_path)
+        try:
+            try:
+                row_count = conn.execute("SELECT COUNT(*) FROM story_events").fetchone()[0]
+            except sqlite3.OperationalError:
+                row_count = 0
+        finally:
+            conn.close()
+        file_count = len(list((project_root / ".story-system" / "events").glob("chapter_*.events.json")))
+        print(json.dumps({"ok": row_count >= 0, "sqlite_rows": row_count, "event_files": file_count}, ensure_ascii=False))
+        return
+
+    if args.chapter:
+        path = _events_file(project_root, args.chapter)
+        events = json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
+        print(json.dumps({"chapter": args.chapter, "events": events}, ensure_ascii=False))
+        return
+
+    db_path = project_root / ".webnovel" / "index.db"
+    conn = sqlite3.connect(db_path)
+    try:
+        columns = ["event_id", "chapter", "event_type", "subject", "payload_json"]
+        rows = conn.execute(
+            "SELECT event_id, chapter, event_type, subject, payload_json FROM story_events ORDER BY chapter DESC, id DESC LIMIT 200"
+        ).fetchall()
+    finally:
+        conn.close()
+    print(json.dumps({"events": [dict(zip(columns, row)) for row in rows]}, ensure_ascii=False))
+
+# webnovel-writer/scripts/data_modules/webnovel.py
+p_story_events = sub.add_parser("story-events", help="转发到 story_events.py")
+p_story_events.add_argument("args", nargs=argparse.REMAINDER)
+```
+
+在 `dashboard/app.py` 按现有 `_get_db()` + `_fetchall_safe()` 模式增加只读接口:
+
+```python
+@app.get("/api/story-events")
+def list_story_events(chapter: Optional[int] = None, limit: int = 200):
+    with closing(_get_db()) as conn:
+        if chapter is not None:
+            return _fetchall_safe(
+                conn,
+                "SELECT * FROM story_events WHERE chapter = ? ORDER BY id DESC LIMIT ?",
+                (chapter, limit),
+            )
+        return _fetchall_safe(
+            conn,
+            "SELECT * FROM story_events ORDER BY chapter DESC, id DESC LIMIT ?",
+            (limit,),
+        )
+
+
+@app.get("/api/story-events/health")
+def story_event_health():
+    with closing(_get_db()) as conn:
+        event_rows = _fetchall_safe(conn, "SELECT COUNT(*) AS count FROM story_events")
+        proposal_rows = _fetchall_safe(
+            conn,
+            "SELECT COUNT(*) AS count FROM override_contracts WHERE record_type = 'amend_proposal' AND status = 'pending'",
+        )
+        return {
+            "story_events": event_rows[0]["count"] if event_rows else 0,
+            "pending_amend_proposals": proposal_rows[0]["count"] if proposal_rows else 0,
+        }
+```
+
+`docs/operations/operations.md` 这一轮必须补三段最小运维内容:
+
+- `preflight`:检查 `.story-system/events/` 是否存在、`story_events` 表是否可查
+- `health`:执行 `webnovel story-events --health`
+- `backup`:备份 `.story-system/` 与 `.webnovel/index.db`
+
+- [ ] **Step 3: 新建文档并跑 Phase 4 回归**
+
+Run:
+
+```bash
+python -m pytest \
+  webnovel-writer/scripts/data_modules/tests/test_story_event_schema.py \
+  webnovel-writer/scripts/data_modules/tests/test_event_log_store.py \
+  webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py \
+  webnovel-writer/scripts/data_modules/tests/test_override_ledger_service.py \
+  webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py \
+  -q --no-cov
+```
+
+Expected: 全部通过
+
+- [ ] **Step 4: 最终提交**
+
+```bash
+git add webnovel-writer/scripts/story_events.py \
+        webnovel-writer/scripts/data_modules/webnovel.py \
+        webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py \
+        webnovel-writer/scripts/data_modules/tests/test_event_log_store.py \
+        webnovel-writer/dashboard/app.py \
+        README.md \
+        docs/architecture/story-system-phase4.md \
+        docs/architecture/overview.md \
+        docs/guides/commands.md \
+        docs/operations/operations.md \
+        docs/superpowers/README.md
+git commit -m "docs: document story system phase4 event log and override ledger"
+```
+
+---
+
+## Spec Coverage Check
+
+- `13.5 Phase 4:统一事件主链`
+  - canonical event log:Task 1
+  - 事件到投影稳定映射:Task 2
+  - 事件到 amend proposal 触发规则:Task 3
+
+- `8.5 override ledger 的新定位`
+  - `soft_deviation / contract_override / amend_proposal`:Task 3
+
+- `10.2 / 10.4`
+  - accepted events 持久化与 amend proposal:Task 1 / Task 3
+
+- `17.2 / 17.3`
+  - dashboard / health / ops 接入:Task 4
+
+---
+
+## Placeholder Scan
+
+- 没有使用 `TODO / TBD`
+- 没有把 override ledger 写成“以后再扩”
+- 没有提前进入 Phase 5 的旧链路降级
+
+---
+
+## Next Plan
+
+Phase 4 之后才进入:
+
+1. `Phase 5 Legacy Downgrade`