1
0
Эх сурвалжийг харах

Merge branch 'feat/long-term-memory-v5.5.5'

长期记忆系统 v5.5.5:
- 新增 memory module (schema/store/writer/bootstrap/compactor/orchestrator/budget)
- 结构化章节上下文与审查管线
- context-agent/data-agent/consistency-checker/continuity-checker 重构
- 6个skill SKILL.md重写 (plan/write/review/resume/dashboard/init)
- snapshot_manager竞态修复、store原子性保护、ID碰撞降低
- pytest统一入口,249测试全通过,覆盖率90%+
lingfengQAQ 2 сар өмнө
parent
commit
94f1205d9d
70 өөрчлөгдсөн 7777 нэмэгдсэн , 1310 устгасан
  1. 1 1
      .claude-plugin/marketplace.json
  2. 1 1
      .coveragerc
  3. 4 3
      README.md
  4. 30 0
      docs/README.md
  5. 24 0
      docs/commands.md
  6. 335 0
      docs/light-memory-system.md
  7. 701 0
      docs/light-retrofit-plan.md
  8. 616 0
      docs/long-term-memory-architecture-v2.md
  9. 510 0
      docs/long-term-memory-research-report.md
  10. 406 0
      docs/storyteller-implementation-plan.md
  11. 438 0
      docs/storyteller-paper-summary.md
  12. 2 5
      pytest.ini
  13. 1 1
      webnovel-writer/.claude-plugin/plugin.json
  14. 43 1
      webnovel-writer/agents/consistency-checker.md
  15. 197 173
      webnovel-writer/agents/context-agent.md
  16. 45 0
      webnovel-writer/agents/continuity-checker.md
  17. 134 160
      webnovel-writer/agents/data-agent.md
  18. 93 0
      webnovel-writer/scripts/chapter_outline_loader.py
  19. 8 1
      webnovel-writer/scripts/data_modules/__init__.py
  20. 10 0
      webnovel-writer/scripts/data_modules/config.py
  21. 52 3
      webnovel-writer/scripts/data_modules/context_manager.py
  22. 194 2
      webnovel-writer/scripts/data_modules/index_manager.py
  23. 28 0
      webnovel-writer/scripts/data_modules/memory/__init__.py
  24. 182 0
      webnovel-writer/scripts/data_modules/memory/bootstrap.py
  25. 52 0
      webnovel-writer/scripts/data_modules/memory/budget.py
  26. 111 0
      webnovel-writer/scripts/data_modules/memory/compactor.py
  27. 217 0
      webnovel-writer/scripts/data_modules/memory/orchestrator.py
  28. 152 0
      webnovel-writer/scripts/data_modules/memory/schema.py
  29. 253 0
      webnovel-writer/scripts/data_modules/memory/store.py
  30. 223 0
      webnovel-writer/scripts/data_modules/memory/writer.py
  31. 67 0
      webnovel-writer/scripts/data_modules/schemas.py
  32. 9 6
      webnovel-writer/scripts/data_modules/snapshot_manager.py
  33. 21 2
      webnovel-writer/scripts/data_modules/state_manager.py
  34. 39 0
      webnovel-writer/scripts/data_modules/state_validator.py
  35. 1 0
      webnovel-writer/scripts/data_modules/tests/test_config.py
  36. 152 0
      webnovel-writer/scripts/data_modules/tests/test_context_manager.py
  37. 518 0
      webnovel-writer/scripts/data_modules/tests/test_coverage_boost.py
  38. 182 0
      webnovel-writer/scripts/data_modules/tests/test_data_modules.py
  39. 2 0
      webnovel-writer/scripts/data_modules/tests/test_entity_linker_cli.py
  40. 78 0
      webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py
  41. 78 0
      webnovel-writer/scripts/data_modules/tests/test_memory_bootstrap.py
  42. 64 0
      webnovel-writer/scripts/data_modules/tests/test_memory_orchestrator.py
  43. 43 0
      webnovel-writer/scripts/data_modules/tests/test_memory_schema.py
  44. 36 0
      webnovel-writer/scripts/data_modules/tests/test_memory_schema_compat.py
  45. 206 0
      webnovel-writer/scripts/data_modules/tests/test_memory_store.py
  46. 51 0
      webnovel-writer/scripts/data_modules/tests/test_memory_writer.py
  47. 5 0
      webnovel-writer/scripts/data_modules/tests/test_migrate_state_to_sqlite.py
  48. 2 0
      webnovel-writer/scripts/data_modules/tests/test_rag_adapter.py
  49. 2 0
      webnovel-writer/scripts/data_modules/tests/test_relationship_graph.py
  50. 2 0
      webnovel-writer/scripts/data_modules/tests/test_sql_state_manager.py
  51. 31 0
      webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py
  52. 0 1
      webnovel-writer/scripts/data_modules/tests/test_state_validator.py
  53. 2 0
      webnovel-writer/scripts/data_modules/tests/test_style_sampler_cli.py
  54. 223 0
      webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py
  55. 21 0
      webnovel-writer/scripts/data_modules/tests/test_workflow_manager.py
  56. 25 0
      webnovel-writer/scripts/data_modules/webnovel.py
  57. 56 1
      webnovel-writer/scripts/extract_chapter_context.py
  58. 86 0
      webnovel-writer/scripts/review_pipeline.py
  59. 35 0
      webnovel-writer/scripts/status_reporter.py
  60. 13 2
      webnovel-writer/scripts/workflow_manager.py
  61. 21 27
      webnovel-writer/skills/webnovel-dashboard/SKILL.md
  62. 8 8
      webnovel-writer/skills/webnovel-init/SKILL.md
  63. 188 395
      webnovel-writer/skills/webnovel-plan/SKILL.md
  64. 46 150
      webnovel-writer/skills/webnovel-resume/SKILL.md
  65. 38 32
      webnovel-writer/skills/webnovel-resume/references/workflow-resume.md
  66. 113 111
      webnovel-writer/skills/webnovel-review/SKILL.md
  67. 240 216
      webnovel-writer/skills/webnovel-write/SKILL.md
  68. 1 1
      webnovel-writer/skills/webnovel-write/references/step-3-review-gate.md
  69. 8 6
      webnovel-writer/skills/webnovel-write/references/workflow-details.md
  70. 1 1
      webnovel-writer/skills/webnovel-write/references/writing/genre-hook-payoff-library.md

+ 1 - 1
.claude-plugin/marketplace.json

@@ -11,7 +11,7 @@
     {
       "name": "webnovel-writer",
       "description": "长篇网文创作系统(skills + agents + data chain + RAG)",
-      "version": "5.5.4",
+      "version": "5.5.5",
       "author": {
         "name": "lingfengQAQ"
       },

+ 1 - 1
.coveragerc

@@ -1,5 +1,5 @@
 [run]
-source = .claude/scripts/data_modules
+source = webnovel-writer/scripts/data_modules
 omit =
     */tests/*
 

+ 4 - 3
README.md

@@ -118,7 +118,8 @@ model: sonnet
 
 | 版本 | 说明 |
 |------|------|
-| **v5.5.4 (当前)** | 补齐写作链提示词强约束(流程硬约束、中文思维写作约束、Step 职责边界);统一中文化审查/润色/Agent 报告文案;清理文档内部版本号与版本历史,降低与插件发版版本混淆。 |
+| **v5.5.5 (当前)** | 新增长期记忆闭环:写前注入 `long_term_memory`,写后沉淀 `memory_facts` 到 `memory_scratchpad.json`;补齐 `memory` 运维子命令。 |
+| **v5.5.4** | 补齐写作链提示词强约束(流程硬约束、中文思维写作约束、Step 职责边界);统一中文化审查/润色/Agent 报告文案;清理文档内部版本号与版本历史,降低与插件发版版本混淆。 |
 | **v5.5.3** | 新增统一 `preflight` 预检命令;写作链 CLI 示例统一为 UTF-8 运行方式,收口文档中的长 shell 预检片段并降低 Windows 终端乱码风险。 |
 | **v5.5.2** | 支持将详细大纲中的章节名同步到正文文件名;修复 workflow_manager 在无参 find_project_root monkeypatch 下的兼容性问题。 |
 | **v5.5.1** | 修复卷级单文件大纲在上下文快照中的章节提取问题;补齐命令文档中遗漏的 `/webnovel-dashboard` 与 `/webnovel-learn`。 |
@@ -133,11 +134,11 @@ model: sonnet
 
 1. 先在本地同步版本信息:
    ```bash
-   python -X utf8 webnovel-writer/scripts/sync_plugin_version.py --version 5.5.4 --release-notes "本次版本说明"
+   python -X utf8 webnovel-writer/scripts/sync_plugin_version.py --version 5.5.5 --release-notes "本次版本说明"
    ```
 2. 提交并推送版本变更(`README.md`、`plugin.json`、`marketplace.json`)。
 3. 打开仓库的 Actions 页面,选择 `Plugin Release`。
-4. 输入与当前仓库元数据一致的 `version`(例如 `5.5.4`)和用于 GitHub Release 的 `release_notes`。
+4. 输入与当前仓库元数据一致的 `version`(例如 `5.5.5`)和用于 GitHub Release 的 `release_notes`。
 5. 工作流会执行以下动作:
    - 校验 `plugin.json`、`marketplace.json` 与 README 当前版本已经一致
    - 校验当前版本与输入的 `version` 一致

+ 30 - 0
docs/README.md

@@ -3,6 +3,12 @@
 本目录承载 `README.md` 之外的详细说明,按模块拆分:
 
 - [架构与模块](#架构与模块)
+- [LIGHT 长期记忆系统](#light-长期记忆系统)
+- [LIGHT 改造计划](#light-改造计划)
+- [长期记忆研究报告](#长期记忆研究报告)
+- [长期记忆新架构](#长期记忆新架构)
+- [STORYTELLER 论文总结](#storyteller-论文总结)
+- [STORYTELLER 落地方案](#storyteller-落地方案)
 - [命令详解](#命令详解)
 - [RAG 与配置](#rag-与配置)
 - [题材模板](#题材模板)
@@ -12,6 +18,30 @@
 
 - `architecture.md`:系统架构、核心理念、双 Agent、六维审查
 
+## LIGHT 长期记忆系统
+
+- `light-memory-system.md`:LIGHT 三层记忆架构、数据流、落地实现建议
+
+## LIGHT 改造计划
+
+- `light-retrofit-plan.md`:基于当前项目的 LIGHT 分层记忆改造方案与执行阶段
+
+## 长期记忆研究报告
+
+- `long-term-memory-research-report.md`:长期记忆论文、基准与开源项目调研报告
+
+## 长期记忆新架构
+
+- `long-term-memory-architecture-v2.md`:基于调研结论重规划的长期记忆架构
+
+## STORYTELLER 论文总结
+
+- `storyteller-paper-summary.md`:STORYTELLER 论文方法、模块、流程与项目启发总结
+
+## STORYTELLER 落地方案
+
+- `storyteller-implementation-plan.md`:基于当前项目的 STORYTELLER 结构化章纲落地实施方案
+
 ## 命令详解
 
 - `commands.md`:`/webnovel-*` 命令详细说明

+ 24 - 0
docs/commands.md

@@ -99,3 +99,27 @@
 产出:
 
 - `.webnovel/project_memory.json`
+
+## 长期记忆运维命令(CLI)
+
+用途:查看、查询、回填和手动更新长期记忆(`memory_scratchpad.json`)。
+
+示例:
+
+```bash
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" memory stats
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" memory query --category character_state --subject xiaoyan
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" memory dump
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" memory conflicts
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" memory bootstrap
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" memory update --chapter 100 --data '{...}'
+```
+
+常用子命令:
+
+- `memory stats`:查看总量、状态分布、分类统计
+- `memory query`:按 `category/subject/status` 过滤查询
+- `memory dump`:导出完整 scratchpad 内容
+- `memory conflicts`:查看同主键 active 冲突项
+- `memory bootstrap`:从 `index.db` 与 `summaries` 回填初始长期记忆
+- `memory update`:对指定章节结果执行一次手动映射写入

+ 335 - 0
docs/light-memory-system.md

@@ -0,0 +1,335 @@
+# LIGHT 长期记忆系统说明
+
+## 文档目标
+
+本文档将论文中的 LIGHT 系统整理成可落地的工程方案,重点说明三层记忆结构、读写链路、核心数据结构,以及在产品化实现时需要重点处理的问题。
+
+## 一句话定义
+
+LIGHT 不是单纯把上下文窗口做大,而是把长期记忆拆成三层:
+
+- 情节记忆 `episodic memory`:负责从历史对话中检索证据
+- 工作记忆 `working memory`:负责保留最近上下文
+- 草稿板 `scratchpad`:负责沉淀长期稳定的高价值事实
+
+回答时由统一的记忆编排器组合这三层记忆,再交给生成模型。
+
+## 设计目标
+
+- 支持超长会话下的稳定问答
+- 降低“上下文很长但还是忘事”的问题
+- 兼顾近期上下文、远期事实和长期偏好
+- 为信息更新、偏好跟随、跨轮总结提供结构化基础
+
+## 总体架构
+
+```text
+┌────────────────────────────────────────────────────┐
+│                    用户对话流                      │
+└────────────────────────────────────────────────────┘
+                         │
+                         ▼
+┌────────────────────────────────────────────────────┐
+│                  回合后处理器                      │
+├────────────────────────────────────────────────────┤
+│ 1. 保存原始 turn 到会话存储                        │
+│ 2. 提取 key-value 和摘要,写入情节记忆             │
+│ 3. 提炼显著事实,增量更新 scratchpad              │
+└────────────────────────────────────────────────────┘
+                         │
+                         ▼
+┌────────────────────────────────────────────────────┐
+│                  用户新问题到达                    │
+└────────────────────────────────────────────────────┘
+                         │
+                         ▼
+┌────────────────────────────────────────────────────┐
+│                  记忆编排器                        │
+├────────────────────────────────────────────────────┤
+│ 1. 检索 episodic top-k                             │
+│ 2. 读取最近 N 轮 working memory                    │
+│ 3. 对 scratchpad 分块并过滤相关内容                │
+│ 4. 组装 prompt,交给回答模型                       │
+└────────────────────────────────────────────────────┘
+```
+
+## 三层记忆拆解
+
+### 1. 情节记忆 `episodic memory`
+
+作用:
+
+- 存储历史对话中的离散事件与事实证据
+- 在远距离问题中召回“当时具体说过什么”
+- 为答案提供可追溯的历史片段
+
+写入方式:
+
+- 每轮 `user-assistant turn pair` 结束后执行一次
+- 提取结构化 `key-value pairs`
+- 生成该轮摘要 `summary`
+- 对结构化结果或摘要做向量化
+- 将原始对话片段与索引共同存入向量库
+
+建议字段:
+
+```json
+{
+  "session_id": "session-001",
+  "turn_id": 1842,
+  "timestamp": "2026-03-19T18:00:00+08:00",
+  "entities": [
+    {"key": "居住城市", "value": "苏州"},
+    {"key": "宠物名字", "value": "团子"}
+  ],
+  "summary": "用户提到已搬到苏州,并养了一只叫团子的猫。",
+  "raw_span": "原始对话片段",
+  "embedding_text": "居住城市=苏州;宠物名字=团子;用户提到已搬到苏州,并养了一只叫团子的猫。"
+}
+```
+
+工程建议:
+
+- 不要只存全文向量,优先存“摘要 + 结构化提取”的索引文本
+- 检索后返回原始片段,避免摘要失真带来的二次误导
+- `top-k` 不要盲目调大,过多召回会引入噪声
+
+### 2. 工作记忆 `working memory`
+
+作用:
+
+- 保留最近若干轮原始对话
+- 维持局部连贯性、代词解析、刚更新的信息
+- 防止系统只看远期记忆,忽略当前上下文
+
+实现方式:
+
+- 直接从会话存储读取最近 `N` 轮
+- 或按 `token budget` 截断,而不是写死固定轮数
+
+工程建议:
+
+- 优先按 token 上限裁剪,而不是按回合数裁剪
+- 最近轮的优先级通常高于远期记忆
+- 回答冲突时,应优先检查工作记忆中是否存在更近更新
+
+### 3. 草稿板 `scratchpad`
+
+作用:
+
+- 沉淀对长期任务真正重要的高层事实
+- 存储偏好、长期约束、时间线、未完成事项等
+- 避免只依赖相似度检索导致关键信息漏召回
+
+写入方式:
+
+- 每轮结束后由 LLM 提炼“显著信息”
+- 将新信息合并到现有 scratchpad
+- 超过阈值后执行压缩
+
+推荐分槽位组织,而不是一整段自由文本:
+
+```json
+{
+  "profile": [
+    "用户当前住在苏州"
+  ],
+  "preferences": [
+    "默认使用简体中文",
+    "倾向简洁回答"
+  ],
+  "instructions": [
+    "代码注释使用中文",
+    "避免未验证结论"
+  ],
+  "timeline": [
+    "2025-12: 从杭州搬到苏州",
+    "2026-04: 计划去东京出差"
+  ],
+  "open_loops": [
+    "简历修改尚未完成"
+  ]
+}
+```
+
+工程建议:
+
+- 将 `profile / preferences / instructions / timeline / open_loops` 分开维护
+- 超过长度阈值后做压缩,但要保留高优先级槽位
+- 回答前不要整块全部注入,应先按问题相关性过滤
+
+## 读写链路
+
+### 写入链路
+
+每轮对话结束后:
+
+1. 保存原始 turn 到会话存储
+2. 用提取模型生成 `key-value + summary`
+3. 写入情节记忆索引
+4. 更新 scratchpad
+5. 超过阈值时压缩 scratchpad
+
+### 读取链路
+
+收到新问题后:
+
+1. 对问题做向量化
+2. 从情节记忆中召回 `top-k`
+3. 从会话存储取最近若干轮 working memory
+4. 将 scratchpad 分块
+5. 对每块做相关性过滤
+6. 将三层记忆拼装为回答上下文
+7. 调用生成模型作答
+
+## 关键数据存储
+
+建议至少拆成四类存储:
+
+- `conversation_store`:保存原始对话 turn
+- `episodic_index`:保存摘要、结构化提取、embedding、原始片段引用
+- `scratchpad_store`:保存长期摘要与槽位化事实
+- `memory_metadata`:保存版本号、状态、更新时间、冲突标记
+
+可选状态字段:
+
+- `active`:当前有效
+- `outdated`:已过期
+- `contradicted`:被冲突信息覆盖
+- `tentative`:不确定信息,需后续确认
+
+## 记忆编排器职责
+
+记忆编排器是系统核心,职责包括:
+
+- 控制每类记忆的 token 预算
+- 决定检索数量与排序策略
+- 在冲突信息出现时做优先级裁决
+- 过滤与问题无关的 scratchpad 块
+- 统一输出给回答模型的上下文格式
+
+一个简单的优先级顺序可以是:
+
+1. 当前轮明确指令
+2. working memory 中的最近事实
+3. scratchpad 中的长期约束与偏好
+4. episodic memory 中召回的历史证据
+
+## 最小可用实现(MVP)
+
+最小版本只需要五个组件:
+
+- `conversation_store`
+- `episodic_indexer`
+- `scratchpad_manager`
+- `memory_orchestrator`
+- `response_generator`
+
+伪代码:
+
+```python
+def on_turn_end(session_id, user_msg, assistant_msg):
+    save_raw_turn(session_id, user_msg, assistant_msg)
+
+    memory_item = extract_kv_and_summary(user_msg, assistant_msg)
+    vector_store.upsert(session_id, memory_item)
+
+    scratchpad = load_scratchpad(session_id)
+    scratchpad = update_scratchpad(scratchpad, user_msg, assistant_msg)
+    if token_len(scratchpad) > 30000:
+        scratchpad = compress_scratchpad(scratchpad, target_tokens=15000)
+    save_scratchpad(session_id, scratchpad)
+
+
+def answer(session_id, question):
+    episodic_docs = retrieve_topk(session_id, question, k=15)
+    recent_turns = load_recent_turns(session_id, token_budget=8000)
+    scratchpad = load_scratchpad(session_id)
+    scratch_chunks = semantic_chunk(scratchpad)
+    filtered_scratch = filter_relevant_chunks(question, scratch_chunks)
+
+    prompt = build_prompt(
+        question=question,
+        episodic_memory=episodic_docs,
+        working_memory=recent_turns,
+        scratchpad=filtered_scratch,
+    )
+    return llm_generate(prompt)
+```
+
+## 与普通 RAG 的区别
+
+普通 RAG 常见流程是:
+
+- 文本切块
+- 向量检索
+- 拼接上下文
+
+LIGHT 的差异在于:
+
+- 有独立的 working memory,保证近期连续性
+- 有 scratchpad,维护高层抽象事实
+- 有持续写入机制,而不是只在回答前做一次检索
+- 更接近“记忆系统”,而不是单次检索增强
+
+## 落地时最难的四个问题
+
+### 1. 信息更新
+
+同一事实会变化,例如“住在杭州”后来变成“搬到苏州”。系统不能只追加,不做状态管理。
+
+### 2. 矛盾消解
+
+不同时间的记忆可能互相冲突。系统需要判断:
+
+- 哪条是最新的
+- 哪条是已过期的
+- 哪条只是暂时猜测
+
+### 3. Scratchpad 膨胀
+
+如果长期只追加不整理,scratchpad 会退化成另一份噪声上下文,导致检索和回答质量持续下降。
+
+### 4. 写入污染
+
+每轮写入依赖 LLM 提取。如果提取错误,错误会长期保留并反复影响后续回答。
+
+## 产品化增强建议
+
+如果从论文原型继续往前做,建议增加以下能力:
+
+- 混合召回:结合 dense、keyword、时间排序和 rerank
+- 冲突检查:回答前先做 memory conflict check
+- 偏好单独建表:用户偏好和长期指令不要完全混在 scratchpad 中
+- 版本化更新:对关键事实保留变更历史和生效时间
+- 可观察性:记录每次回答用了哪些记忆,便于审计和调试
+
+## 适用场景
+
+LIGHT 适合以下系统:
+
+- 长对话助手
+- 需要记住用户偏好的智能体
+- 多轮写作/策划协作系统
+- 长期任务跟踪与提醒系统
+- 小说创作助手中的角色、设定、剧情记忆系统
+
+## 对本项目的启发
+
+如果把 LIGHT 思路引入当前小说写作系统,可以这样映射:
+
+- 情节记忆:存章节事件、角色状态变化、伏笔和回收线索
+- 工作记忆:存当前章节任务书、最近章节摘要、当前写作约束
+- 草稿板:存角色设定、长期世界观规则、主线目标、未回收伏笔
+
+这样可以把“长篇连载的一致性问题”从单次检索,升级成持续维护的记忆闭环。
+
+## 总结
+
+LIGHT 的核心价值不在于更大的上下文窗口,而在于把长期记忆拆成:
+
+- 可检索的历史证据层
+- 保持局部连贯的短期层
+- 稳定沉淀长期事实的抽象层
+
+工程上最值得复用的是这套分层记忆与统一编排思路,而不是某个固定模型或具体实现细节。

+ 701 - 0
docs/light-retrofit-plan.md

@@ -0,0 +1,701 @@
+# LIGHT 改造现有系统计划
+
+## 文档目标
+
+本文档给出一份可直接执行的改造计划,用于在当前 `webnovel-writer` 基础上引入 LIGHT 风格的三层记忆系统,同时尽量复用现有的 `state.json`、`index.db`、`vectors.db`、`ContextManager` 与写作工作流。
+
+目标不是推翻现有架构,而是在低风险前提下补齐:
+
+- 独立的长期摘要记忆层 `scratchpad`
+- 统一的记忆编排层 `memory orchestrator`
+- 面向写作场景的冲突裁决与记忆状态管理
+
+## 当前系统现状摘要
+
+当前项目已经具备以下能力:
+
+- `state.json`:精简运行态与章节元信息
+- `index.db`:实体、别名、状态变化、关系、追读力、审查指标
+- `vectors.db`:章节摘要与场景切片的向量检索
+- `ContextManager`:多源上下文拼装与预算裁剪
+- `extract_chapter_context.py`:写作前统一上下文入口
+- `workflow_manager.py`:写作工作流状态机与中断恢复
+
+当前缺口主要有两项:
+
+- 缺独立维护的长期摘要记忆层
+- 缺统一调度 `working / episodic / scratchpad` 的记忆编排器
+
+## 改造目标
+
+### 总目标
+
+将系统升级为三层记忆架构:
+
+- `working memory`:近期写作上下文
+- `episodic memory`:可检索历史证据
+- `scratchpad memory`:长期高密度摘要记忆
+
+### 子目标
+
+- 不破坏现有 `/webnovel-write` 主流程
+- 不修改已有数据模型的核心职责边界
+- 新增能力尽量通过“增量接入”完成
+- 保持现有 CLI 与测试体系可持续演进
+
+## 目标架构
+
+```text
+Skills / Agents
+   │
+   ▼
+Chapter Context Facade
+   │  extract_chapter_context.py
+   │  ContextManager
+   ▼
+Memory Orchestrator
+   ├── Working Memory
+   ├── Episodic Memory
+   └── Scratchpad Memory
+   ▼
+Final Context Pack
+
+写入侧:
+Data Agent / Step 5
+   ├── state.json
+   ├── index.db
+   ├── vectors.db
+   ├── summaries/
+   └── memory_scratchpad.json
+```
+
+## 设计原则
+
+### 1. 最小侵入
+
+优先复用:
+
+- `ContextManager`
+- `extract_chapter_context.py`
+- `StateManager`
+- `IndexManager`
+- `RAGAdapter`
+
+### 2. 单一职责
+
+- `state.json` 继续保存运行态精简信息
+- `index.db` 继续保存结构化事实
+- `vectors.db` 继续保存语义检索向量
+- `memory_scratchpad.json` 专门保存长期摘要记忆
+
+### 3. 渐进替换
+
+先“旁路接入”,再逐步把逻辑迁入编排器,而不是一次性重写 `ContextManager`
+
+### 4. 可回滚
+
+每个阶段都能独立上线和回退,避免一次改太大导致写作链失稳
+
+## 记忆分层映射
+
+### Working Memory
+
+来源:
+
+- 本章大纲
+- 最近章节摘要
+- 当前 `state.json`
+- `reader_signal`
+- `writing_guidance`
+
+当前主要载体:
+
+- `scripts/extract_chapter_context.py`
+- `scripts/data_modules/context_manager.py`
+
+### Episodic Memory
+
+来源:
+
+- `index.db`
+- `vectors.db`
+- `.webnovel/summaries/chNNNN.md`
+
+当前主要载体:
+
+- `scripts/data_modules/index_manager.py`
+- `scripts/data_modules/rag_adapter.py`
+- `scripts/data_modules/state_manager.py`
+
+### Scratchpad Memory
+
+新增:
+
+- `.webnovel/memory_scratchpad.json`
+
+用途:
+
+- 汇总长期有效事实
+- 维护角色当前稳定状态
+- 沉淀世界规则与长期约束
+- 存储时间线与未回收开放环
+- 服务写作前的高密度摘要注入
+
+## 新增模块规划
+
+建议新增以下文件:
+
+```text
+webnovel-writer/scripts/data_modules/
+├── memory_orchestrator.py
+├── scratchpad_manager.py
+├── scratchpad_schema.py
+└── memory_conflict_resolver.py
+```
+
+### `scratchpad_schema.py`
+
+职责:
+
+- 定义 `memory_scratchpad.json` 结构
+- 提供默认值与校验函数
+
+建议结构:
+
+```json
+{
+  "story_facts": [],
+  "character_facts": [],
+  "world_rules": [],
+  "timeline": [],
+  "open_loops": [],
+  "reader_promises": [],
+  "active_constraints": [],
+  "meta": {
+    "version": 1,
+    "last_updated": ""
+  }
+}
+```
+
+### `scratchpad_manager.py`
+
+职责:
+
+- 读写 `memory_scratchpad.json`
+- 从章节结果增量更新长期摘要
+- 定期压缩与去噪
+- 按主题或章节生成相关片段
+
+建议能力:
+
+- `load()`
+- `save()`
+- `update_from_chapter_result()`
+- `compress_if_needed()`
+- `filter_for_chapter()`
+- `mark_fact_status()`
+
+### `memory_conflict_resolver.py`
+
+职责:
+
+- 处理新旧事实冲突
+- 给事实打状态标签
+
+建议状态:
+
+- `active`
+- `outdated`
+- `contradicted`
+- `tentative`
+
+建议处理对象:
+
+- 角色境界/地点/归属变更
+- 关系变化
+- 世界规则的显式修订
+- 伏笔从 active 到 resolved
+
+### `memory_orchestrator.py`
+
+职责:
+
+- 统一调度三层记忆
+- 控制预算与优先级
+- 输出最终 context pack
+
+建议接口:
+
+- `build_memory_pack(chapter: int) -> dict`
+- `load_working_memory(chapter: int) -> dict`
+- `load_episodic_memory(chapter: int) -> dict`
+- `load_scratchpad_memory(chapter: int) -> dict`
+- `resolve_conflicts(...)`
+- `assemble(...)`
+
+## 现有文件改造点
+
+### 第一优先级
+
+#### `scripts/data_modules/context_manager.py`
+
+改造目标:
+
+- 从“多源上下文拼装器”升级为“编排器调用方”
+- 新增 `scratchpad` section
+- 后续逐步委托给 `MemoryOrchestrator`
+
+建议改动:
+
+- `_build_pack()` 中加入 scratchpad 内容
+- `SECTION_ORDER` 中加入或重新排序 `scratchpad`
+- 新增从 orchestrator 获取结果的兼容入口
+
+#### `scripts/extract_chapter_context.py`
+
+改造目标:
+
+- 接入 `MemoryOrchestrator`
+- 输出更完整的三层记忆摘要
+
+建议改动:
+
+- 在 `build_chapter_context_payload()` 中加载 orchestrator 结果
+- 保留现有 JSON/text 输出格式兼容
+- 新增 `scratchpad_signal` 或 `long_term_memory` 字段
+
+#### `scripts/data_modules/state_manager.py`
+
+改造目标:
+
+- 在 `process_chapter_result()` 完成后触发 scratchpad 更新
+
+建议改动:
+
+- 在章节结果保存成功后调用 `ScratchpadManager.update_from_chapter_result()`
+- 失败时只记录 warning,不影响主流程完成
+
+### 第二优先级
+
+#### `scripts/data_modules/rag_adapter.py`
+
+改造目标:
+
+- 让 episodic memory 检索对“长期事实块”更友好
+
+建议改动:
+
+- 为 scratchpad 相关片段预留可选向量化能力
+- 允许对 `chunk_type=scratchpad` 做检索
+- 不作为第一阶段必做项
+
+#### `scripts/status_reporter.py`
+
+改造目标:
+
+- 利用 scratchpad 生成更高层视角的健康报告
+
+建议改动:
+
+- 报告中增加“长期约束漂移”“未回收开放环”“记忆冲突提醒”
+
+#### Dashboard
+
+改造目标:
+
+- 增加 scratchpad 可视化
+
+建议改动:
+
+- 展示当前长期事实摘要
+- 展示冲突事实与待确认事实
+
+## 新数据文件规划
+
+新增:
+
+```text
+.webnovel/memory_scratchpad.json
+```
+
+建议字段:
+
+- `story_facts`:当前剧情稳定事实
+- `character_facts`:角色稳定状态与重要变化
+- `world_rules`:长期不可违背设定
+- `timeline`:关键事件时间线
+- `open_loops`:未回收伏笔、未完成承诺、待兑现悬念
+- `reader_promises`:已对读者抛出的期待点
+- `active_constraints`:当前章节仍需遵守的重要约束
+- `meta`:版本、更新时间、压缩次数
+
+建议事实项结构:
+
+```json
+{
+  "id": "fact-char-xiaoyan-realm-001",
+  "category": "character_facts",
+  "subject": "xiaoyan",
+  "field": "realm",
+  "value": "筑基三层",
+  "status": "active",
+  "source_chapter": 128,
+  "updated_at": "2026-03-19T20:00:00+08:00",
+  "evidence": [
+    "ch0128_summary",
+    "state_change:xiaoyan:realm:128"
+  ],
+  "confidence": 0.95
+}
+```
+
+## 分阶段执行计划
+
+### Phase 0:设计冻结与接口先行
+
+目标:
+
+- 明确数据结构和接口,不立即改主流程
+
+任务:
+
+1. 定义 `memory_scratchpad.json` schema
+2. 定义 `ScratchpadManager` 最小接口
+3. 定义 `MemoryOrchestrator` 输入输出格式
+4. 补充设计文档与测试样例
+
+交付物:
+
+- `scratchpad_schema.py`
+- 设计说明文档
+- 测试用 fixture 样例
+
+验收标准:
+
+- schema 稳定
+- 接口命名与现有风格一致
+- 不影响现有命令
+
+### Phase 1:落地 Scratchpad 存储层
+
+目标:
+
+- 让项目拥有独立的长期摘要记忆文件
+
+任务:
+
+1. 实现 `scratchpad_schema.py`
+2. 实现 `scratchpad_manager.py`
+3. 支持加载、保存、初始化默认结构
+4. 编写基础单元测试
+
+交付物:
+
+- `memory_scratchpad.json`
+- `ScratchpadManager`
+- 对应 tests
+
+验收标准:
+
+- 文件创建与读写稳定
+- schema 校验通过
+- 空项目可自动初始化
+
+### Phase 2:接入章节写后更新链
+
+目标:
+
+- 在现有 Step 5 后自动维护 scratchpad
+
+任务:
+
+1. 在 `StateManager.process_chapter_result()` 后接入 scratchpad 更新
+2. 从以下来源提取增量事实:
+   - `entities_new`
+   - `state_changes`
+   - `relationships_new`
+   - `chapter_meta`
+   - `plot_threads.foreshadowing`
+3. 增加错误隔离,保证 scratchpad 失败不阻断主流程
+
+交付物:
+
+- 写后更新逻辑
+- 日志与 warning 机制
+
+验收标准:
+
+- 写完一章后 scratchpad 有可见增量
+- 工作流不因 scratchpad 失败而中断
+
+### Phase 3:接入读取链
+
+目标:
+
+- 在写作前上下文中引入 scratchpad
+
+任务:
+
+1. 在 `ContextManager` 中加入 scratchpad section
+2. 在 `extract_chapter_context.py` 中输出长期记忆摘要
+3. 为 scratchpad 分配独立预算
+4. 增加按章节相关性过滤
+
+交付物:
+
+- 上下文新增 `scratchpad` / `long_term_memory`
+- text 输出中新增相关章节说明
+
+验收标准:
+
+- 第 N 章上下文可看到长期约束与关键开放环
+- 上下文长度可控
+
+### Phase 4:引入 Memory Orchestrator
+
+目标:
+
+- 从“多源拼装”升级为“分层记忆编排”
+
+任务:
+
+1. 新建 `memory_orchestrator.py`
+2. 把以下逻辑迁入 orchestrator:
+   - Working memory 读取
+   - Episodic memory 检索
+   - Scratchpad 过滤
+   - 冲突检查
+   - 最终 pack 组装
+3. 让 `ContextManager` 调用 orchestrator,而不是自己做全部组装
+
+交付物:
+
+- `MemoryOrchestrator`
+- `ContextManager` 兼容适配
+
+验收标准:
+
+- 输出结构与旧接口兼容
+- 逻辑可单测
+- 能独立关闭 orchestrator,回退旧路径
+
+### Phase 5:冲突裁决与记忆状态化
+
+目标:
+
+- 解决“旧事实覆盖新事实”的问题
+
+任务:
+
+1. 新建 `memory_conflict_resolver.py`
+2. 对关键事实打状态:
+   - `active`
+   - `outdated`
+   - `contradicted`
+   - `tentative`
+3. 处理常见冲突:
+   - 人物境界变化
+   - 人物位置变化
+   - 势力归属变化
+   - 伏笔状态变化
+   - 关系状态变化
+
+交付物:
+
+- 冲突解析器
+- 冲突样例测试
+
+验收标准:
+
+- 最新事实优先
+- 旧事实保留历史但不进入高优先上下文
+
+### Phase 6:高级能力增强
+
+目标:
+
+- 进一步提升检索与可观察性
+
+任务:
+
+1. 可选支持 scratchpad 向量化
+2. 在 dashboard 中增加 scratchpad 可视化
+3. 增加记忆命中日志
+4. 在健康报告中加入记忆冲突提醒
+
+交付物:
+
+- dashboard 页面扩展
+- 观测指标扩展
+
+验收标准:
+
+- 能追踪每次写作用了哪些记忆
+- 可以人工审计长期记忆质量
+
+## 实施顺序建议
+
+推荐严格按以下顺序做:
+
+1. Phase 0
+2. Phase 1
+3. Phase 2
+4. Phase 3
+5. Phase 4
+6. Phase 5
+7. Phase 6
+
+原因:
+
+- 先落存储层,后接写链
+- 先让数据流起来,再做编排优化
+- 先保证稳定,再做智能化冲突处理
+
+## 测试计划
+
+### 单元测试
+
+新增测试文件建议:
+
+```text
+scripts/data_modules/tests/
+├── test_scratchpad_schema.py
+├── test_scratchpad_manager.py
+├── test_memory_orchestrator.py
+└── test_memory_conflict_resolver.py
+```
+
+重点测试:
+
+- scratchpad 初始化
+- 章节结果增量写入
+- 冲突事实状态变化
+- 上下文过滤与预算裁剪
+- orchestrator 输出兼容性
+
+### 集成测试
+
+建议扩展:
+
+- `test_context_manager.py`
+- `test_extract_chapter_context.py`
+- `test_state_manager_extra.py`
+
+验证场景:
+
+1. 连续两章更新同一角色境界
+2. 伏笔状态从 active 变为 resolved
+3. 写作上下文中出现正确的长期约束
+4. scratchpad 失败时主流程仍能成功
+
+### 回归测试
+
+必须覆盖:
+
+- `/webnovel-write` 主流程不回归
+- `/webnovel-review` 不受影响
+- 旧项目无 `memory_scratchpad.json` 时可兼容
+
+## 风险与控制策略
+
+### 风险 1:上下文变长
+
+问题:
+
+- 引入 scratchpad 后上下文膨胀
+
+控制:
+
+- 单独预算
+- 分槽位裁剪
+- 只注入相关块
+
+### 风险 2:错误事实长期污染
+
+问题:
+
+- scratchpad 写入错误后会长期影响后续章节
+
+控制:
+
+- 先基于结构化数据生成,而不是纯文本自由总结
+- 保留 evidence 字段
+- 增加 `tentative` 状态
+
+### 风险 3:和现有 ContextManager 职责冲突
+
+问题:
+
+- 逻辑迁移过快,导致重复拼装
+
+控制:
+
+- Phase 3 前只做增量 section
+- Phase 4 再逐步收口到 orchestrator
+
+### 风险 4:影响写作主链稳定性
+
+问题:
+
+- 新模块异常阻断主流程
+
+控制:
+
+- scratchpad 写入采用 best-effort
+- 失败只记录 warning
+- 主流程成功判定不依赖 scratchpad
+
+## 里程碑定义
+
+### M1:Scratchpad 可用
+
+完成标志:
+
+- 可以创建并维护 `.webnovel/memory_scratchpad.json`
+
+### M2:写后自动更新
+
+完成标志:
+
+- 每章写作完成后 scratchpad 自动更新
+
+### M3:写前自动读取
+
+完成标志:
+
+- 写作上下文中包含长期摘要记忆
+
+### M4:统一编排上线
+
+完成标志:
+
+- `MemoryOrchestrator` 成为主读取路径
+
+### M5:冲突管理完成
+
+完成标志:
+
+- 新旧事实状态化,长期记忆可持续演进
+
+## 推荐首批实现范围
+
+如果只做一个最小可落地版本,建议范围限定为:
+
+1. 新增 `memory_scratchpad.json`
+2. 实现 `scratchpad_schema.py`
+3. 实现 `scratchpad_manager.py`
+4. 在 `state_manager.py` 写后更新 scratchpad
+5. 在 `context_manager.py` 写前读取 scratchpad
+
+这五项完成后,就已经有一版可运行的 LIGHT MVP。
+
+## 最终结论
+
+当前项目不需要重构重来,而应该走一条“补层”的路线:
+
+- 保留现有 `state/index/rag/workflow`
+- 新增独立 `scratchpad`
+- 再以 `memory orchestrator` 把三层记忆统一起来
+
+这是对当前工程最稳、最符合现状、也最容易逐步验收的改造方案。

+ 616 - 0
docs/long-term-memory-architecture-v2.md

@@ -0,0 +1,616 @@
+# 长期记忆新架构规划(V2)
+
+## 文档目标
+
+基于长期记忆方向的论文与工程调研结果,重新规划 `webnovel-writer` 的目标架构。
+
+这版架构不再把重点放在“补一个 scratchpad 文件”,而是把系统正式升级为:
+
+- `记忆写入层`
+- `记忆存储层`
+- `记忆编排层`
+- `写作消费层`
+
+四层协同的长期记忆系统。
+
+## 一句话结论
+
+新架构应采用:
+
+- `LIGHT` 的三层记忆分层
+- `Mem0` 的独立 memory layer 思路
+- `Zep / Graphiti` 的时态事实与关系建模
+
+也就是说,目标不是“在 ContextManager 上继续堆逻辑”,而是:
+
+**把记忆系统独立出来,让 ContextManager 退化成消费记忆编排结果的适配层。**
+
+## 为什么要重规划
+
+旧方案的问题在于:
+
+- 仍然以 `ContextManager` 为中心
+- 更像“现有系统增强”
+- 不够清晰地区分“写记忆”和“读记忆”
+
+基于调研后,新的核心认识是:
+
+1. 长期记忆的关键不只是检索,而是写入与更新
+2. 长期记忆必须有自己的生命周期管理
+3. 事实、关系、时间线应当进入同一记忆体系
+4. 上下文组装只是消费层,不应继续承担全部核心职责
+
+## 新架构核心原则
+
+### 1. 记忆系统独立化
+
+新增独立 `memory` 子系统,负责:
+
+- 记忆抽取
+- 记忆归档
+- 记忆压缩
+- 记忆检索
+- 冲突裁决
+
+### 2. 记忆分层化
+
+固定分成三层:
+
+- `Working Memory`
+- `Episodic Memory`
+- `Semantic/Scratchpad Memory`
+
+### 3. 时态事实化
+
+角色状态、关系、势力、伏笔等信息都必须支持:
+
+- 当前有效
+- 历史版本
+- 变更时间
+- 来源证据
+
+### 4. 消费与存储解耦
+
+- `ContextManager` 只负责消费记忆编排结果
+- 写作技能/查询技能不直接拼接多源原始数据
+
+## 目标总架构
+
+```text
+┌──────────────────────────────────────────────────────────────┐
+│                    Skills / Agents / Dashboard              │
+│   write / review / query / resume / dashboard              │
+└──────────────────────────┬───────────────────────────────────┘
+                           │
+                           ▼
+┌──────────────────────────────────────────────────────────────┐
+│                    Context Facade Layer                     │
+│  ContextManager / extract_chapter_context / query adapter   │
+│  只负责接收 Memory Orchestrator 输出并适配不同命令           │
+└──────────────────────────┬───────────────────────────────────┘
+                           │
+                           ▼
+┌──────────────────────────────────────────────────────────────┐
+│                    Memory Orchestrator                      │
+│  统一读取 Working / Episodic / Semantic Memory             │
+│  做预算控制、相关性过滤、冲突裁决、输出 context pack         │
+└───────────────┬───────────────────────┬──────────────────────┘
+                │                       │
+                ▼                       ▼
+     ┌───────────────────┐   ┌───────────────────────────────┐
+     │ Working Memory    │   │ Long-Term Memory Layer        │
+     │ 近期写作上下文      │   │ Episodic + Semantic           │
+     └───────────────────┘   └───────────────────────────────┘
+                                          │
+                                          ▼
+                       ┌──────────────────────────────────────┐
+                       │ Memory Write Pipeline                │
+                       │ Chapter result -> extract -> update  │
+                       └──────────────────────────────────────┘
+                                          │
+                                          ▼
+                       ┌──────────────────────────────────────┐
+                       │ Memory Storage Layer                 │
+                       │ state/index/vectors/scratchpad/graph │
+                       └──────────────────────────────────────┘
+```
+
+## 四层模型
+
+## 1. 记忆写入层
+
+这是新架构中最重要的新层。
+
+### 职责
+
+- 从章节产物中提取长期有效信息
+- 判断哪些信息进入哪种记忆层
+- 对已有事实做更新、失效、冲突标记
+- 生成可供后续读取的标准化记忆项
+
+### 写入入口
+
+主入口仍然挂在 Step 5 / Data Agent 完成之后。
+
+输入来源:
+
+- `entities_new`
+- `entities_appeared`
+- `state_changes`
+- `relationships_new`
+- `chapter_meta`
+- `summary`
+- `plot_threads`
+- 后续可扩展:审查报告、用户偏好、人工修订
+
+### 建议新增模块
+
+```text
+scripts/data_modules/memory/
+├── write_pipeline.py
+├── extractors/
+│   ├── fact_extractor.py
+│   ├── relationship_extractor.py
+│   ├── timeline_extractor.py
+│   └── promise_extractor.py
+```
+
+### 设计理由
+
+这部分吸收的是 `Mem0` 的思路:
+
+- 记忆不是原样存对话
+- 而是先做显著信息提炼,再写入 memory layer
+
+## 2. 记忆存储层
+
+新架构不废弃现有存储,而是重新定义职责。
+
+### 2.1 `state.json`
+
+继续保留,但职责进一步收缩为:
+
+- 当前写作运行态
+- 主角快照
+- 进度
+- strand tracker
+- 少量即时风险提示
+
+不再承载长期知识沉淀。
+
+### 2.2 `index.db`
+
+继续作为结构化事实库,但要明确升级为:
+
+- `episodic structured memory`
+
+主要存:
+
+- 实体
+- 别名
+- 关系
+- 关系事件
+- 状态变化
+- chapter/meta
+- 追读力和审查指标
+
+### 2.3 `vectors.db`
+
+继续作为:
+
+- `episodic retrieval memory`
+
+主要存:
+
+- 章节摘要
+- 场景切片
+- 后续可扩展:
+  - 长期摘要块 embedding
+  - 伏笔块 embedding
+  - 角色关系摘要块 embedding
+
+### 2.4 `memory_scratchpad.json`
+
+保留,但重新定义为:
+
+- `semantic memory cache`
+
+它不应该只是一个杂项摘要文件,而应该是一个可压缩、高密度、面向消费的长期摘要缓存。
+
+建议结构:
+
+```json
+{
+  "character_state": [],
+  "story_state": [],
+  "world_rules": [],
+  "timeline": [],
+  "open_loops": [],
+  "reader_promises": [],
+  "active_constraints": [],
+  "meta": {}
+}
+```
+
+### 2.5 `memory_graph.db` 或图表扩展
+
+这是新规划中和旧方案最大的差异之一。
+
+建议新增图记忆层,哪怕初期只是 SQLite 表,也要预留语义:
+
+- 实体节点
+- 关系边
+- 时间有效性
+- 来源章节
+- 版本变化
+
+形式上可以有两种方案:
+
+1. 保守方案:
+   - 先继续放在 `index.db`
+   - 新增 graph-oriented tables
+2. 进阶方案:
+   - 新建 `memory_graph.db`
+
+第一阶段建议用保守方案,避免基础设施过重。
+
+## 3. 记忆编排层
+
+这是新的核心中台。
+
+### 职责
+
+- 按章节或查询意图读取三层记忆
+- 根据任务类型动态分配预算
+- 合并 working / episodic / semantic memory
+- 对冲突事实做优先级裁决
+- 输出标准化 context pack
+
+### 建议新增模块
+
+```text
+scripts/data_modules/memory/
+├── orchestrator.py
+├── budget_manager.py
+├── relevance_filter.py
+├── conflict_resolver.py
+└── memory_pack.py
+```
+
+### 三层记忆定义
+
+#### Working Memory
+
+来源:
+
+- 本章大纲
+- 最近摘要
+- 当前状态
+- 当前 chapter guidance
+- 当前债务与风险
+
+特点:
+
+- 强时效
+- 高优先级
+- 不需要长期存档
+
+#### Episodic Memory
+
+来源:
+
+- `index.db`
+- `vectors.db`
+- `summaries`
+
+特点:
+
+- 保留历史证据
+- 可追溯
+- 适合回答“发生过什么”
+
+#### Semantic Memory
+
+来源:
+
+- `memory_scratchpad.json`
+- 图记忆层生成的高阶摘要
+
+特点:
+
+- 保留稳定抽象事实
+- 适合回答“当前应该认为是真的是什么”
+
+### 编排流程
+
+```text
+写作请求
+   │
+   ▼
+意图分析
+   │
+   ├── chapter_write
+   ├── consistency_check
+   ├── continuity_query
+   └── review
+   ▼
+Memory Orchestrator
+   │
+   ├── load working memory
+   ├── retrieve episodic memory
+   ├── load semantic memory
+   ├── resolve conflicts
+   ├── apply budget
+   └── build final pack
+```
+
+## 4. 写作消费层
+
+这一层包括:
+
+- `ContextManager`
+- `extract_chapter_context.py`
+- `/webnovel-write`
+- `/webnovel-query`
+- `/webnovel-review`
+
+### 新职责
+
+- 不直接拼装底层数据
+- 只根据任务类型请求 memory pack
+- 将 memory pack 渲染成:
+  - text
+  - json
+  - prompt context
+
+### 这意味着什么
+
+`ContextManager` 不再是记忆逻辑中心,而是:
+
+- 一个适配器
+- 一个模板层
+- 一个输出格式层
+
+## 模块目录规划
+
+建议新增独立目录:
+
+```text
+webnovel-writer/scripts/data_modules/
+├── memory/
+│   ├── __init__.py
+│   ├── schema.py
+│   ├── storage.py
+│   ├── write_pipeline.py
+│   ├── orchestrator.py
+│   ├── conflict_resolver.py
+│   ├── relevance_filter.py
+│   ├── budget_manager.py
+│   ├── summary_compactor.py
+│   └── graph_memory.py
+```
+
+### 各模块职责
+
+#### `schema.py`
+
+- 定义记忆项结构
+- 定义状态字段与版本字段
+
+#### `storage.py`
+
+- 封装对 `memory_scratchpad.json`、`index.db`、`vectors.db` 的统一读写
+
+#### `write_pipeline.py`
+
+- 章节完成后写入记忆
+
+#### `orchestrator.py`
+
+- 对外提供统一 `build_memory_pack()`
+
+#### `conflict_resolver.py`
+
+- 处理 active / outdated / contradicted / tentative
+
+#### `relevance_filter.py`
+
+- 按章节目标、大纲关键词、实体中心过滤长期记忆
+
+#### `budget_manager.py`
+
+- 控制不同任务类型的 token 预算
+
+#### `summary_compactor.py`
+
+- 负责 scratchpad 压缩和去噪
+
+#### `graph_memory.py`
+
+- 封装关系图、时间线和事实图语义
+
+## 数据模型重定义
+
+### 统一记忆项
+
+建议引入统一记忆对象结构:
+
+```json
+{
+  "id": "mem-001",
+  "layer": "semantic",
+  "category": "character_state",
+  "subject": "xiaoyan",
+  "field": "realm",
+  "value": "筑基三层",
+  "status": "active",
+  "source": {
+    "chapter": 128,
+    "type": "state_change"
+  },
+  "evidence": [
+    "state_change:xiaoyan:realm:128",
+    "summary:ch0128"
+  ],
+  "updated_at": "2026-03-19T20:00:00+08:00",
+  "confidence": 0.95
+}
+```
+
+### 为什么需要统一记忆项
+
+这样可以统一处理:
+
+- 角色状态
+- 世界规则
+- 伏笔
+- 读者承诺
+- 关系变化
+- 时间线事件
+
+## 新架构的数据流
+
+## 写入流
+
+```text
+正文完成
+   │
+   ▼
+Data Agent
+   │
+   ├── 原有写入:state/index/vectors/summaries
+   └── Memory Write Pipeline
+           │
+           ├── 提取长期事实
+           ├── 合并到 semantic memory
+           ├── 更新 graph memory
+           ├── 标记冲突和过期项
+           └── 刷新 scratchpad cache
+```
+
+## 读取流
+
+```text
+第N章写作 / 查询 / 审查
+   │
+   ▼
+Memory Orchestrator
+   │
+   ├── Working Memory
+   ├── Episodic Memory
+   ├── Semantic Memory
+   ├── Graph Memory
+   └── Conflict Resolution
+   ▼
+Memory Pack
+   ▼
+ContextManager / Query Renderer / Review Renderer
+```
+
+## 对现有模块的角色调整
+
+### `ContextManager`
+
+从:
+
+- 核心上下文拼装器
+
+变为:
+
+- memory pack 渲染器
+- 模板权重应用器
+
+### `StateManager`
+
+从:
+
+- 运行态状态 + 部分知识写入
+
+变为:
+
+- 运行态状态管理器
+- 章节结果写入入口
+- 记忆写入流水线触发器
+
+### `IndexManager`
+
+从:
+
+- 结构化索引库
+
+变为:
+
+- episodic structured memory backend
+
+### `RAGAdapter`
+
+从:
+
+- 场景检索器
+
+变为:
+
+- episodic retrieval backend
+
+## 推荐实施顺序
+
+### Stage 1:搭 memory 子系统骨架
+
+- 新增 `memory/` 目录
+- 定义统一记忆 schema
+- 抽出 scratchpad 存储
+
+### Stage 2:接写入管线
+
+- 章节写后自动更新 semantic memory
+- 建立冲突状态字段
+
+### Stage 3:接编排器
+
+- `ContextManager` 改为消费 orchestrator
+- `extract_chapter_context.py` 改为基于 memory pack 输出
+
+### Stage 4:接图记忆
+
+- 先在 `index.db` 扩展 graph-like tables
+- 后续如有必要再拆独立图库
+
+### Stage 5:扩展 dashboard 和观测
+
+- 查看长期事实
+- 查看冲突项
+- 查看记忆命中来源
+
+## 关键取舍
+
+### 不建议做的事
+
+- 继续把所有逻辑堆进 `ContextManager`
+- 只加一个 `memory_scratchpad.json` 就认为问题解决
+- 第一阶段就引入完整图数据库
+- 直接照搬 Letta/MemGPT 的 agent runtime
+
+### 建议做的事
+
+- 把 memory 作为独立子系统设计
+- 先解决记忆写入问题,再优化读取
+- 用统一记忆项结构降低后续复杂度
+- 让图语义先以轻量方式落在现有 SQLite 中
+
+## 最终目标
+
+最终系统应具备以下能力:
+
+1. 写完一章后,系统能自动沉淀长期有效事实
+2. 多章之后,系统能区分历史事实和当前事实
+3. 写作前,系统能自动提供“近期上下文 + 历史证据 + 长期约束”
+4. 当关系、设定、伏笔变化时,系统能保留版本与证据
+5. 审查和查询模块能共享同一套长期记忆底座
+
+## 总结
+
+基于调研,新的最佳架构不是“在现有系统上补一个功能点”,而是:
+
+**把长期记忆升级为一个独立子系统,并让现有写作系统围绕它重新分层。**
+
+这版架构比旧方案更适合长期演化,也更符合当前长期记忆研究和工程实践的主流方向。

+ 510 - 0
docs/long-term-memory-research-report.md

@@ -0,0 +1,510 @@
+# 长期记忆论文与项目调研报告
+
+## 文档目标
+
+本文档汇总大语言模型长期记忆方向中较有代表性的论文、基准和开源项目,重点回答四个问题:
+
+- 长期记忆问题到底在解决什么
+- 这个领域近两年的主流技术路线是什么
+- 哪些项目已经做到较强的工程落地
+- 对当前 `webnovel-writer` 最值得借鉴的能力是什么
+
+说明:
+
+- 本报告优先使用论文原文、arXiv 页面和项目官方仓库
+- 报告不是穷举式综述,而是面向架构决策的工程调研
+- 截止本次检索时间,最新参考到 2026 年 3 月可见资料
+
+## 一句话结论
+
+当前长期记忆方向已经很明确地形成了三条主线:
+
+1. `外部记忆 + 检索`:把历史信息存到外部存储,再按需召回
+2. `分层记忆 + 编排`:把近期上下文、历史证据、长期摘要分层管理
+3. `图结构 / 时态记忆`:用知识图谱或时态图来处理事实更新、关系变化和时间推理
+
+对我们项目最直接有用的不是“更大上下文”,而是:
+
+- 独立的长期摘要层
+- 统一的记忆编排层
+- 面向事实更新的状态管理
+
+## 为什么长期记忆仍然是独立问题
+
+代表性基准都在说明同一件事:
+
+- 单纯增加上下文窗口,并不能稳定解决长期记忆
+- 模型在多会话、多时间点、事实更新、跨章节整合时仍然容易退化
+- 记忆系统的核心不只是“存”,还包括“写入、压缩、检索、更新、冲突裁决”
+
+这一点在以下基准中都被反复验证:
+
+- `LoCoMo`
+- `LongMemEval`
+- `BEAM`
+
+## 评测基准
+
+### 1. LoCoMo
+
+论文:
+
+- `Evaluating Very Long-Term Conversational Memory of LLM Agents`
+- arXiv:2402.17753
+- 链接:<https://arxiv.org/abs/2402.17753>
+
+关键点:
+
+- 数据集聚焦“超长期对话记忆”
+- 每条对话平均约 `300 turns`
+- 平均约 `9K tokens`
+- 覆盖最多 `35 sessions`
+- 评测问答、事件总结、多模态对话生成
+
+价值:
+
+- 是很多后续 memory 系统对比的常用基准
+- 强调多轮会话与长期连续性,而不只是单文档检索
+
+来源:LoCoMo 论文摘要页 <https://arxiv.org/abs/2402.17753>
+
+### 2. LongMemEval
+
+论文:
+
+- `LongMemEval: Benchmarking Chat Assistants on Long-Term Interactive Memory`
+- arXiv:2410.10813
+- 链接:<https://arxiv.org/abs/2410.10813>
+
+关键点:
+
+- 明确拆出五种长期记忆能力:
+  - 信息抽取
+  - 多会话推理
+  - 时间推理
+  - 知识更新
+  - 拒答
+- 包含 `500` 个精心构造的问题
+- 论文指出,商业助手和长上下文模型在持续交互上的准确率会出现约 `30%` 下滑
+
+价值:
+
+- 比纯“召回率”更接近真实助手场景
+- 很适合衡量“事实更新”和“跨会话推理”
+
+来源:LongMemEval 论文摘要页 <https://arxiv.org/abs/2410.10813>
+
+### 3. BEAM
+
+论文:
+
+- `Beyond a Million Tokens: Benchmarking and Enhancing Long-Term Memory in LLMs`
+- arXiv:2510.27246
+- 链接:<https://arxiv.org/abs/2510.27246>
+
+关键点:
+
+- 生成最长可达 `10M tokens` 的连贯对话
+- 构建 `100` 段对话和 `2000` 个校验问题
+- 同时提出 `LIGHT` 记忆框架
+- 作者报告 LIGHT 相对强基线平均提升 `3.5% - 12.69%`
+
+价值:
+
+- 把“超长上下文”与“长期记忆”区分得更清楚
+- 对我们这种长篇创作系统非常有参考价值
+
+来源:BEAM/LIGHT 论文摘要页 <https://arxiv.org/abs/2510.27246>
+
+## 代表论文
+
+### A. 早期外部记忆路线
+
+#### MemoryBank
+
+- 论文:`MemoryBank: Enhancing Large Language Models with Long-Term Memory`
+- arXiv:2305.10250
+- 链接:<https://arxiv.org/abs/2305.10250>
+
+核心思想:
+
+- 把对话历史转成外部记忆
+- 检索相关记忆参与回答
+- 引入类似“遗忘曲线”的更新机制
+- 强调用户画像和长期陪伴式交互
+
+意义:
+
+- 很早就提出“记忆不是纯存档,而是要更新和遗忘”
+- 适合人格化、陪伴式、个性化场景
+
+局限:
+
+- 更偏对话陪伴
+- 工程结构相对较早,抽象层级不够细
+
+#### LongMem
+
+- 论文:`Augmenting Language Models with Long-Term Memory`
+- arXiv:2306.07174
+- 链接:<https://arxiv.org/abs/2306.07174>
+
+核心思想:
+
+- 冻结 backbone LLM 作为 memory encoder
+- 旁路引入 retriever/reader side-network
+- 把长期上下文缓存为外部记忆
+
+意义:
+
+- 代表“模型架构级长期记忆”的路线
+- 不只是 prompt engineering,而是显式引入 memory 模块
+
+局限:
+
+- 对我们当前项目这种插件式工程系统,可复用性不如外部存储方案高
+
+### B. 分层记忆与虚拟上下文路线
+
+#### MemGPT
+
+- 论文:`MemGPT: Towards LLMs as Operating Systems`
+- arXiv:2310.08560
+- 链接:<https://arxiv.org/abs/2310.08560>
+
+核心思想:
+
+- 把 LLM 看成“应用层”
+- 通过类似操作系统的虚拟内存管理,把不同记忆层在上下文和外部存储间切换
+- 用 memory tier 和 control flow 管理超出窗口的历史
+
+意义:
+
+- 是“分层记忆编排”这条路线的关键代表作
+- 强调 memory paging、分层存储和自管理
+
+局限:
+
+- 偏 agent runtime
+- 对小说写作系统来说,直接照搬成本较高
+
+#### LIGHT
+
+- 论文:`Beyond a Million Tokens: Benchmarking and Enhancing Long-Term Memory in LLMs`
+- arXiv:2510.27246
+- 链接:<https://arxiv.org/abs/2510.27246>
+
+核心思想:
+
+- 明确拆成三层:
+  - `episodic memory`
+  - `working memory`
+  - `scratchpad`
+- 用统一编排器组合三者
+
+意义:
+
+- 和我们项目当前改造方向最接近
+- 对“长期写作上下文”非常适配
+
+局限:
+
+- 目前还是较新的研究工作
+- 更像工程原型,不是现成平台
+
+### C. 生产化记忆层路线
+
+#### Mem0
+
+- 论文:`Mem0: Building Production-Ready AI Agents with Scalable Long-Term Memory`
+- arXiv:2504.19413
+- 链接:<https://arxiv.org/abs/2504.19413>
+- 官方仓库:<https://github.com/mem0ai/mem0>
+
+核心思想:
+
+- 动态提取对话中的显著信息
+- 做记忆 consolidation
+- 检索时优先使用精炼后的 memory,而不是整段历史
+- 还有 graph-based 变体
+
+官方声称:
+
+- 在 LoCoMo 上相对 OpenAI Memory 有 `+26%` 提升
+- 相对 full-context 有更低延迟与 token 成本
+
+工程价值:
+
+- 非常重视生产环境指标:延迟、token 成本、SDK 接入
+- 是“记忆层产品化”的代表项目
+
+局限:
+
+- 更适合通用 agent/assistant
+- 对小说写作的结构化剧情状态,还需要定制
+
+来源:
+
+- 论文摘要页 <https://arxiv.org/abs/2504.19413>
+- 官方仓库 README <https://github.com/mem0ai/mem0>
+
+#### Zep
+
+- 论文:`Zep: A Temporal Knowledge Graph Architecture for Agent Memory`
+- arXiv:2501.13956
+- 链接:<https://arxiv.org/abs/2501.13956>
+
+核心思想:
+
+- 用 `Graphiti` 作为时态知识图谱引擎
+- 将对话和业务数据融合到一个可追踪历史的时态图中
+- 强调动态知识集成、历史关系维护、低延迟检索
+
+论文报告:
+
+- 在 DMR 上优于 MemGPT
+- 在 LongMemEval 上最高可提升 `18.5%`
+- 相对基线延迟降低 `90%`
+
+工程价值:
+
+- 很适合“事实会变、关系会变、时间有强语义”的场景
+- 对角色关系、势力变化、设定修订这类小说问题很有参考性
+
+来源:Zep 论文摘要页 <https://arxiv.org/abs/2501.13956>
+
+#### MIRIX
+
+- 论文:`MIRIX: Multi-Agent Memory System for LLM-Based Agents`
+- arXiv:2507.07957
+- 链接:<https://arxiv.org/abs/2507.07957>
+
+核心思想:
+
+- 设计六类记忆:
+  - Core
+  - Episodic
+  - Semantic
+  - Procedural
+  - Resource Memory
+  - Knowledge Vault
+- 用多 Agent 协同管理更新和检索
+- 扩展到多模态记忆
+
+意义:
+
+- 展示了“记忆分工继续细化”的趋势
+- 证明未来 memory system 不一定只分三层
+
+局限:
+
+- 架构更复杂
+- 对当前项目来说有参考价值,但不适合第一阶段直接引入
+
+来源:MIRIX 论文摘要页 <https://arxiv.org/abs/2507.07957>
+
+## 代表开源项目
+
+### 1. Letta
+
+- 仓库:<https://github.com/letta-ai/letta>
+- 说明:原 MemGPT 项目已经并入 Letta
+
+官方定位:
+
+- `stateful agents`
+- 强调 agent 可跨会话持续存在、学习、自我改进
+
+值得关注的点:
+
+- 长寿命 agent
+- memory blocks
+- 持久化 agent runtime
+
+对我们的启发:
+
+- 适合作为“长期存在的创作 Agent”参考
+- 但它更像完整 agent 平台,不适合直接嵌入当前插件架构
+
+来源:
+
+- Letta 仓库 README <https://github.com/letta-ai/letta>
+- 官方说明 <https://www.letta.com/blog/memgpt-and-letta>
+
+### 2. Mem0
+
+- 仓库:<https://github.com/mem0ai/mem0>
+
+官方定位:
+
+- `Universal memory layer for AI Agents`
+
+值得关注的点:
+
+- SDK 化程度高
+- 支持自托管和托管
+- 强调多层 memory、用户偏好、会话和 agent 状态
+
+对我们的启发:
+
+- 非常适合作为“独立 memory layer”的工程参考
+- 尤其适合看它怎么做 API、接入层和 memory 抽取流程
+
+来源:Mem0 官方仓库 README <https://github.com/mem0ai/mem0>
+
+### 3. Graphiti
+
+- 仓库:<https://github.com/getzep/graphiti>
+
+官方定位:
+
+- `Build Real-Time Knowledge Graphs for AI Agents`
+
+特点:
+
+- 时态知识图谱
+- 支持实体、事实关系、原始 episodes、ontology
+- 适合动态环境中的 agent memory
+
+对我们的启发:
+
+- 非常适合角色关系、势力结构、事件时间线
+- 如果后续想做“剧情知识图谱”,Graphiti/Zep 是最值得研究的一类
+
+来源:Graphiti 官方仓库 README <https://github.com/getzep/graphiti>
+
+## 研究趋势总结
+
+### 趋势 1:从“长上下文”转向“记忆系统”
+
+近两年最明确的结论是:
+
+- 长上下文不等于长期记忆
+- 真正有效的方案都在做外部 memory、结构化抽取、分层编排
+
+### 趋势 2:从“检索片段”转向“记忆写入与更新”
+
+早期系统重点是:
+
+- 怎么检索历史
+
+近年的重点已经变成:
+
+- 记忆写什么
+- 什么时候合并
+- 什么时候标记过期
+- 遇到冲突怎么裁决
+
+### 趋势 3:从“平面向量库”转向“图结构和时态结构”
+
+在纯向量检索之外,越来越多系统开始强调:
+
+- entity / relation
+- temporal validity
+- provenance
+- update / invalidation
+
+这对任何“事实会变化”的系统都非常关键。
+
+### 趋势 4:生产系统开始关注成本和延迟
+
+像 Mem0、Zep 这类项目,不再只讲准确率,而是同时强调:
+
+- p95 延迟
+- token 成本
+- 开发者接入成本
+- 托管与可观测性
+
+## 对当前项目最有用的结论
+
+### 最值得借鉴的不是某一个项目,而是三类能力
+
+#### 1. LIGHT 的分层思想
+
+最适合我们当前项目的核心抽象仍然是:
+
+- `working memory`
+- `episodic memory`
+- `scratchpad`
+
+原因:
+
+- 我们已经有 working 和 episodic 的基础
+- 当前最缺的正是 scratchpad
+
+#### 2. Zep/Graphiti 的时态事实处理
+
+对小说系统特别重要的是:
+
+- 同一角色状态会变
+- 同一关系会演化
+- 同一设定可能被修订
+
+所以光做向量检索不够,必须有事实状态和时间语义。
+
+#### 3. Mem0 的工程落地方式
+
+Mem0 很值得借鉴的是:
+
+- memory layer 独立化
+- SDK/接口清晰
+- 生产环境关注延迟和 token 成本
+
+这对我们把长期记忆做成独立模块非常有帮助。
+
+## 对 `webnovel-writer` 的映射建议
+
+### 可以直接吸收的部分
+
+- LIGHT:三层记忆结构
+- Mem0:独立 memory layer 设计
+- Zep/Graphiti:关系和时间线建模方式
+
+### 暂时不建议直接照搬的部分
+
+- Letta:完整 stateful agent 平台,体量过大
+- MIRIX:分类太细,第一阶段会过重
+- LongMem:更偏模型架构级改造,不适合当前插件工程
+
+## 我的建议排序
+
+如果以“对当前项目收益 / 改造成本”排序,我建议优先关注:
+
+1. `LIGHT`
+2. `Mem0`
+3. `Zep / Graphiti`
+4. `MemGPT / Letta`
+5. `MIRIX`
+6. `MemoryBank`
+7. `LongMem`
+
+## 结论
+
+长期记忆方向现在已经很清楚:
+
+- 上下文窗口只是基础设施,不是最终答案
+- 真正有效的是“分层记忆 + 持续写入 + 智能检索 + 冲突管理”
+
+对当前项目来说,最现实的路线不是做一个通用 agent 平台,而是:
+
+- 保留现有 `state.json / index.db / vectors.db`
+- 新增 `scratchpad` 长期摘要层
+- 再加一个统一的 `memory orchestrator`
+
+这条路线与近期论文结论最一致,也最贴合当前系统基础。
+
+## 参考链接
+
+- MemoryBank: <https://arxiv.org/abs/2305.10250>
+- LongMem: <https://arxiv.org/abs/2306.07174>
+- MemGPT: <https://arxiv.org/abs/2310.08560>
+- LoCoMo: <https://arxiv.org/abs/2402.17753>
+- LongMemEval: <https://arxiv.org/abs/2410.10813>
+- Zep: <https://arxiv.org/abs/2501.13956>
+- Mem0 论文: <https://arxiv.org/abs/2504.19413>
+- Mem0 仓库: <https://github.com/mem0ai/mem0>
+- MIRIX: <https://arxiv.org/abs/2507.07957>
+- Graphiti 仓库: <https://github.com/getzep/graphiti>
+- Letta 仓库: <https://github.com/letta-ai/letta>
+- Letta / MemGPT 迁移说明: <https://www.letta.com/blog/memgpt-and-letta>
+- LIGHT / BEAM: <https://arxiv.org/abs/2510.27246>

+ 406 - 0
docs/storyteller-implementation-plan.md

@@ -0,0 +1,406 @@
+# STORYTELLER 落地实施方案
+
+## 文档目标
+
+本文档基于前面对 `STORYTELLER` 论文、当前 `webnovel-writer` 架构和落地边界的讨论,给出一份从 `skills` 与 `agents` 视角出发的修正版实施方案。
+
+目标不是复制论文原系统,而是以最小破坏、最高复用的方式,将“中层情节结构”前移到规划阶段,并让写作、审查、数据回写围绕结构化章纲形成闭环。
+
+## 一句话结论
+
+最优方案不是新增独立的 `webnovel-structure` 主流程,而是:
+
+- 升级 `webnovel-plan`,输出结构化详细大纲
+- 增强 `context-agent`,把结构化章纲组装进写作包
+- 增强 `continuity-checker` 与 `consistency-checker`,分别负责节点覆盖与结构冲突
+- 增强 `data-agent`,负责落库覆盖结果与偏差说明
+
+这样可以在不打碎现有系统的前提下,为写作链补上 `STORYTELLER` 最核心的中层结构层。
+
+## 核心原则
+
+1. 不新增独立主流程 `webnovel-structure`
+2. 结构前移到 `webnovel-plan`
+3. `context-agent` 只聚合,不生成节点
+4. `continuity-checker` 负责结构覆盖
+5. `consistency-checker` 负责结构冲突
+6. `data-agent` 负责落库,不做主判断
+7. 全程向后兼容,无节点章纲照常运行
+
+## 总体架构
+
+```text
+/webnovel-init
+    -> 初始化项目骨架与状态
+
+/webnovel-plan
+    -> 节拍表
+    -> 时间线
+    -> 结构化详细大纲(新增节点字段)
+
+/webnovel-write
+    -> Step 0.5 轻量节点预检
+    -> Step 1 context-agent 组装写作包
+    -> Step 2A writer 按节点扩写
+    -> Step 3 review agents
+    -> Step 5 data-agent 持久化覆盖与偏差
+```
+
+## Skills 视角实施方案
+
+### 1. `webnovel-init`
+
+定位:保留,不做大改。
+
+职责:
+
+- 初始化 `.webnovel/state.json`
+- 初始化 `index.db / vectors.db`
+- 初始化 `大纲/`、`设定集/`、摘要目录等骨架
+
+建议新增但不强制:
+
+- 为后续结构化章纲预留约定说明
+- 为 `index.db` 预留结构化章纲缓存或索引的升级入口
+
+### 2. `webnovel-plan`
+
+定位:本次改造的主战场。
+
+目标:把现有“详细大纲”升级成“结构化详细大纲”。
+
+现有输出保留:
+
+- `大纲/第{volume_id}卷-节拍表.md`
+- `大纲/第{volume_id}卷-时间线.md`
+- `大纲/第{volume_id}卷-详细大纲.md`
+
+每章新增字段:
+
+- `章节起点(CBN)`
+- `推进节点(CPNs)`
+- `章节终点(CEN)`
+- `必须覆盖节点`
+- `本章禁区`
+
+节点格式建议:
+
+`主体 | 动作/变化 | 对象/结果`
+
+示例:
+
+- `萧炎 | 抵达 | 迦南学院入口`
+- `萧炎 | 展示 | 异火控制力`
+- `药老 | 对萧炎产生 | 明确兴趣`
+
+节点数量约束:
+
+- `1 个 CBN`
+- `2-4 个 CPN`
+- `1 个 CEN`
+
+必须覆盖规则:
+
+- 每章必须覆盖节点最多 `4` 个
+- 建议为:`CBN + CEN + 1~2 个核心 CPN`
+
+章间衔接规则:
+
+- 相邻章节 `CEN -> 下一章 CBN` 必须逻辑承接
+- 若为时间跳转章,需在时间字段中明确说明
+
+本章禁区规则:
+
+- 不超过 `5` 条
+- 只写硬禁区,不写风格建议
+
+批次建议:
+
+- 默认 `10章/批`
+- 复杂题材 `8章/批`
+- 简单升级流上限 `12章/批`
+
+不建议:
+
+- 再单独新增常规 `/webnovel-structure`
+- 在 `plan` 之外再维护一份平行节点文件作为主数据源
+
+### 3. `webnovel-write`
+
+定位:保留为唯一主写作入口。
+
+建议流程:
+
+1. `Step 0.5` 轻量节点预检
+2. `Step 1` `context-agent` 读取结构化章纲、状态和记忆,组装写作包
+3. `Step 2A` writer 按 `CBN -> CPNs -> CEN` 扩写正文
+4. `Step 3` review agents 执行结构覆盖与设定一致性检查
+5. `Step 5` `data-agent` 持久化覆盖结果与偏差说明
+
+新增 Step 0.5 的边界:
+
+- 只检查主角或 POV 角色相关节点
+- 第一版仅检查地点、境界/实力层级
+- 仅附加警告,不阻断流程
+- 不做复杂关系推理或自动重规划
+
+### 4. `webnovel-review`
+
+定位:保留,主要用于区间复盘和独立质量检查。
+
+建议增强:
+
+- 支持读取结构化章纲中的节点字段
+- 支持基于章节范围回看 `plan vs actual` 偏移趋势
+
+## Agents 视角实施方案
+
+### 1. `context-agent`
+
+定位:保留,增强为“结构化章纲聚合器”。
+
+新增读取:
+
+- `CBN`
+- `CPNs`
+- `CEN`
+- `必须覆盖节点`
+- `本章禁区`
+
+新增输出板块:
+
+- `情节结构`
+  - `章节起点`
+  - `推进节点`
+  - `章节终点`
+  - `必须覆盖节点`
+  - `本章禁区`
+
+Context Contract 新增字段:
+
+- `plot_structure`
+  - `cbn`
+  - `cpns`
+  - `cen`
+  - `mandatory_nodes`
+  - `prohibitions`
+
+Step 2A 节拍映射:
+
+- 有节点时:`CBN触发 -> CPN推进 -> CPN受阻/变化 -> CEN收束 -> 章末钩子`
+- 无节点时:保持旧节拍
+
+红线新增:
+
+- 情节结构与任务书方向冲突
+
+边界:
+
+- 不生成节点
+- 不修改节点
+- 只负责读取、聚合、压缩成写作包
+
+### 2. `plot-node-agent`
+
+当前判断:第一阶段不作为必须组件。
+
+原因:
+
+- 既然决定把结构前移到 `plan`
+- 那 `CBN/CPN/CEN` 的主产物应由 `plan` 直接生成
+- 不需要在 `write` 阶段再新增重量级 agent 重复规划
+
+保留可能性:
+
+- 若后续发现章纲与正文脱节严重
+- 再考虑引入轻量 `plot-node-agent`
+- 只做写前局部修正,不做整章重规划
+
+### 3. `continuity-checker`
+
+定位:负责“结构覆盖”和“事件承接”。
+
+新增检查:
+
+1. `CBN 承接`
+2. `CEN 落地`
+3. `必须节点覆盖`
+4. `可选节点覆盖(仅统计)`
+
+输出:
+
+- 节点覆盖表
+- 覆盖评级:`A / B / C / F`
+
+成功标准:
+
+- 有节点时,覆盖评级至少 `B`
+
+边界:
+
+- 不判断设定是否越界
+- 不判断禁区是否违背世界规则
+- 只判断“写到了没有、接顺了没有、收住了没有”
+
+### 4. `consistency-checker`
+
+定位:负责“结构与设定冲突”。
+
+新增检查:
+
+1. 是否违反 `本章禁区`
+2. 正文开头方向是否与 `CBN` 冲突
+3. 正文结尾方向是否与 `CEN` 冲突
+4. 关键节点对应的能力、身份、地点是否违背设定
+
+严重度建议:
+
+- 禁区违反:`high`
+- `CEN` 方向冲突:`medium`
+- `CBN` 方向冲突:`medium`
+
+边界:
+
+- 不负责覆盖率评级
+- 不与 `continuity-checker` 重叠
+
+### 5. `ooc-checker`
+
+定位:保留。
+
+增强方式:
+
+- 读取更结构化的角色状态上下文
+- 结合当前章的节点、角色行为红线和本章变化判断 OOC 风险
+
+### 6. `reader-pull-checker / high-point-checker / pacing-checker`
+
+定位:保留,但执行层收口。
+
+建议:
+
+- 默认写作流程强制启用:
+  - `consistency-checker`
+  - `continuity-checker`
+  - `ooc-checker`
+- 其余按题材、章节类型或区间复盘按需启用
+
+原因:
+
+- 新架构已经把部分“晚审”前移到 `plan`
+- 再全量六并发常驻会过重
+
+### 7. `data-agent`
+
+定位:从“主判断者”改成“持久化与偏差记录器”。
+
+继续保留:
+
+- 实体抽取
+- 状态变更
+- 关系更新
+- 摘要生成
+- `memory facts`
+- 向量切片
+
+新增 Step 5B:
+
+- 写入 `plot_coverage`
+- 写入 `plan_deviation_note`
+
+关键规则:
+
+- `data-agent` 不自己重复做完整覆盖判断
+- 优先消费 `continuity-checker` 与 `consistency-checker` 的结果,再写入 `chapter_meta`
+
+推荐写入结构:
+
+```json
+{
+  "plot_coverage": {
+    "cbn_covered": true,
+    "cen_covered": true,
+    "mandatory_hit_rate": 1.0,
+    "coverage_grade": "A",
+    "prohibitions_violated": []
+  },
+  "plan_deviation_note": ""
+}
+```
+
+填写规则:
+
+- `mandatory_hit_rate < 0.8` 时必须填写 `plan_deviation_note`
+- 存在禁区违反时必须填写 `plan_deviation_note`
+
+边界:
+
+- 不重规划后续章纲
+- 不做复杂 SVO 主链重建
+- 不代替 review
+
+## 实施顺序
+
+### Phase 1:最小闭环
+
+1. `skills/webnovel-plan/SKILL.md`
+2. `agents/context-agent.md`
+3. `skills/webnovel-write/SKILL.md`
+4. `agents/continuity-checker.md`
+5. `agents/consistency-checker.md`
+6. `agents/data-agent.md`
+
+原因:
+
+- 所有下游都依赖 `plan` 的新字段
+- 其余模块都只是消费这些字段
+
+### Phase 2:轻量状态增强
+
+1. 在 `index.db` 中补充结构化章纲缓存或索引
+2. 为 `context-agent` 增加“最近实际承接摘要”读取
+3. 为 `review` 增加区间级偏移复盘
+
+### Phase 3:按需局部修正
+
+1. 若出现大量“章纲和正文脱节”
+2. 再评估是否引入轻量 `plot-node-agent`
+3. 仅做写前局部修正,不做完整重规划
+
+## 验证方案
+
+1. **结构化章纲验证**
+   - 执行 `/webnovel-plan`
+   - 确认详细大纲新增:
+     - `CBN`
+     - `CPNs`
+     - `CEN`
+     - `必须覆盖节点`
+     - `本章禁区`
+
+2. **写作包验证**
+   - 执行 `/webnovel-write`
+   - 确认 `context-agent` 输出包含“情节结构”板块
+
+3. **审查验证**
+   - 确认 `continuity-checker` 输出节点覆盖评级
+   - 确认 `consistency-checker` 输出禁区/方向冲突检查
+
+4. **数据回写验证**
+   - 确认 `chapter_meta` 写入 `plot_coverage`
+   - 确认偏差时有 `plan_deviation_note`
+
+5. **向后兼容验证**
+   - 用旧章纲执行 `/webnovel-write`
+   - 全流程不阻断、不报错
+
+## 最终判断
+
+这版方案的重点不是“多加一个系统层”,而是:
+
+- 让 `plan` 产出更可执行的章纲
+- 让 `context-agent` 成为结构化章纲聚合器
+- 让 `review` 围绕节点覆盖与结构冲突形成清晰分工
+- 让 `data-agent` 成为持久化和偏差记录器
+
+这条路线既吸收了 `STORYTELLER` 的核心思想,又避免了额外主流程和重复组件的膨胀。

+ 438 - 0
docs/storyteller-paper-summary.md

@@ -0,0 +1,438 @@
+# STORYTELLER 论文总结
+
+## 文档目标
+
+本文档基于原论文 `STORYTELLER: An Enhanced Plot-Planning Framework for Coherent and Cohesive Story Generation` 进行整理,重点回答以下问题:
+
+- 这篇工作到底解决了什么问题
+- 它的核心方法由哪些模块组成
+- 三阶段生成流程是如何运作的
+- 论文实验结果说明了什么
+- 对当前 `webnovel-writer` 有哪些直接可借鉴之处
+
+说明:
+
+- 本文优先依据 ACL Anthology 上的论文原文整理
+- 内容偏工程视角,不做逐节逐句翻译
+- 截止本次整理时间,未检索到论文官方公开代码仓库
+
+## 一句话结论
+
+`STORYTELLER` 的核心价值,不是换了一个更大的模型,而是把长篇故事生成改造成了一个 `结构化剧情规划 + 实体关系图维护 + 逐章节审查修正` 的闭环系统。
+
+它通过 `SVO` 事件节点、`STORYLINE` 时间线和 `NEKG` 叙事实体知识图谱,把“故事状态”从隐式上下文记忆转成显式可维护结构,因此显著提升了长篇叙事的一致性、连贯性与可控性。
+
+## 论文信息
+
+- 标题:`STORYTELLER: An Enhanced Plot-Planning Framework for Coherent and Cohesive Story Generation`
+- 会议:`Findings of the Association for Computational Linguistics: ACL 2025`
+- 作者:Jiaming Li, Yukun Chen, Ziqiang Liu 等
+- 论文链接:<https://aclanthology.org/2025.findings-acl.1071/>
+- PDF:<https://aclanthology.org/2025.findings-acl.1071.pdf>
+- arXiv:<https://arxiv.org/abs/2506.02347>
+
+## 论文要解决的问题
+
+论文开篇指出,现有自动故事生成方法虽然能写出流畅文本,但在长篇叙事里经常出现以下问题:
+
+- 风格前后不一致
+- 情节逻辑断裂
+- 角色动机突然变化
+- 章节之间衔接松散
+- 重复、冗长、创造性不足
+
+论文认为,问题的根源在于:
+
+- 既有方法往往只提供高层 outline
+- 后续章节或事件生成彼此相对独立
+- 模型对先前剧情、角色关系和事件因果缺乏持续追踪
+
+因此,单纯“先列大纲再扩写”还不够,还需要一个在生成过程中持续参与的动态结构层。
+
+## 核心思想
+
+作者借鉴人类写作者的认知循环,将故事写作抽象为三类持续交互的行为:
+
+- Retrieval:回看已有剧情与实体关系
+- Evaluation:判断新情节是否合理
+- Generation:在当前状态约束下生成新内容
+
+围绕这个思路,`STORYTELLER` 做了三件关键事:
+
+1. 用 `SVO` 三元组把剧情抽象成情节节点
+2. 用 `STORYLINE` 维护按时间推进的事件链
+3. 用 `NEKG` 维护人物、地点、物品与事件关系图
+
+这意味着它不是让模型直接从 prompt 一路写到底,而是先维护一个不断更新的“剧情状态空间”,再在该状态空间内生成文本。
+
+## 核心模块
+
+### 1. NODES:基于 SVO 的情节节点
+
+论文把故事中的关键事件表示为 `Subject-Verb-Object` 三元组,也就是:
+
+- 谁
+- 做了什么
+- 作用到谁 / 什么
+
+这个设计的意义是:
+
+- 把复杂叙事压缩成可比较、可检索、可审查的事件单元
+- 让剧情推进具有明确的结构锚点
+- 降低长文本生成时的漂移风险
+
+论文进一步把章节内节点分成三类:
+
+- `CBN`:Chapter Begin Node,章节起始节点
+- `CPN`:Chapter Plot Node,章节推进节点
+- `CEN`:Chapter End Node,章节结束节点
+
+可以把它理解成:每一章都被拆成“进入状态、推进事件、收束结果”三个层次。
+
+### 2. STORYLINE:时间序列化剧情主线
+
+`STORYLINE` 用来保存生成过程中出现的所有 `NODE`。
+
+它的关键特征是:
+
+- 每个节点都带有 `time_stamp`
+- 所有节点按时间顺序组织
+- 能形成故事全局事件时间线
+
+论文强调,`STORYLINE` 的作用是:
+
+- 保证事件顺序正确
+- 追踪剧情如何逐步演化
+- 为后续节点生成提供最近事件上下文
+
+从工程角度看,`STORYLINE` 相当于一个面向剧情推进的时序状态表。
+
+### 3. NEKG:叙事实体知识图谱
+
+`NEKG` 全称是 `Narrative Entity Knowledge Graph`。
+
+论文中,它负责用图结构维护叙事中的实体与关系:
+
+- 人物
+- 地点
+- 物品
+- 概念
+- 它们之间的互动与关联
+
+论文明确提到,`NEKG` 使用 `Neo4j` 实现,并且每个生成故事都有自己的图实例。
+
+`NEKG` 的主要作用有三点:
+
+- 记录角色互动和对象变化
+- 提供与当前候选剧情有关的关联节点
+- 支持后续剧情扩展与基于关系的推理
+
+如果说 `STORYLINE` 解决的是“发生了什么,先后顺序如何”,那么 `NEKG` 解决的是“谁和谁有关、关系怎么影响后续剧情”。
+
+## 三阶段生成流程
+
+论文把整个系统分成三个阶段。
+
+## Stage 1:高层故事生成
+
+这一阶段先把用户输入压缩成高层叙事框架。
+
+具体包含两步:
+
+### 1. Premise 和 Synopsis 生成
+
+系统先从用户 prompt 中抽取核心信息,生成:
+
+- `Premise`:故事设定、时代背景、环境与社会语境
+- `Synopsis`:主线剧情、人物关系、主要转折
+
+这一步的作用是先把“灵感提示词”变成“具备世界观和主线的故事蓝图”。
+
+### 2. Chapter Titles 和 Abstracts 生成
+
+系统进一步把长篇故事拆成章节,并为每章生成:
+
+- 章节标题
+- 章节摘要
+
+这些摘要不仅用于组织结构,也作为后续中层情节节点生成的输入约束。
+
+简化理解:
+
+- Stage 1 决定整本故事大致写什么
+- 让后续生成不至于一开始就陷入全文自由扩写
+
+## Stage 2:中层情节结构生成
+
+这是整篇论文最关键的部分。
+
+在这一阶段,系统并不直接写正文,而是先为每一章生成节点结构。
+
+### 1. 先生成 CBN 和 CEN
+
+对于每一章,系统会先生成:
+
+- 本章从什么状态开始
+- 本章在什么状态收束
+
+论文指出,这一步不仅参考当前章节,还会考虑前后相邻章节,从而保证章节切分和过渡更自然。
+
+### 2. 再逐个生成 CPN
+
+有了章节起点和终点之后,系统开始填充中间的推进节点 `CPN`。
+
+这里不是直接一次性生成全部节点,而是循环执行:
+
+1. 先根据已有 `CBN / CEN / 已生成 CPN` 生成一个 `Pseudo CPN`
+2. 以该候选节点中的 `S'` 和 `O'` 为线索,从 `NEKG` 检索相关节点
+3. 同时从 `STORYLINE` 提取最近事件
+4. 让 LLM 审查这个 `Pseudo CPN` 是否合理
+5. 合理则接受,不合理则改写成新的 `CPN`
+6. 重复直到当前章节节点链能够自然走向 `CEN`
+
+这个设计很重要,因为它体现了论文真正的创新点:
+
+- 不是先规划完再写,而是边规划边审查
+- 不是只看局部章节摘要,而是持续引用已有剧情状态
+- 不是把图谱当静态附属物,而是把它作为节点审查依据
+
+### 为什么要先生成 Pseudo CPN 再审查
+
+论文给出的原因很现实:
+
+- `STORYLINE` 和 `NEKG` 中数据会不断增长
+- 长上下文和成本限制不允许每次把全部历史都喂给 LLM
+
+所以它先生成候选节点,再围绕该节点做有目标的检索与校验,从而把上下文使用控制在一个较低成本范围内。
+
+从工程角度看,这一步本质上是:
+
+`候选事件生成 -> 相关证据召回 -> 一致性评审 -> 事件确认`
+
+## Stage 3:细粒度正文写作
+
+在完成章节节点结构之后,系统才开始生成自然语言文本块。
+
+正文生成会综合使用:
+
+- 当前章节标题与摘要
+- 当前章节的 `CBN / CPNs / CEN`
+- 上一个文本块
+
+各个文本块生成后被加入列表,最后拼接成完整故事。
+
+这说明 `STORYTELLER` 的正文生成并不是自由创作模式,而是:
+
+- 被章节结构约束
+- 被节点链约束
+- 被前文文本块衔接约束
+
+因此它更像一个“有状态的扩写器”。
+
+## 方法优势
+
+结合论文设计,我认为它的主要优势有以下几点:
+
+### 1. 把长期一致性显式化
+
+传统方法把一致性寄托在模型上下文记忆里。`STORYTELLER` 把一致性拆成两个显式结构:
+
+- 时间上的一致性:交给 `STORYLINE`
+- 实体关系上的一致性:交给 `NEKG`
+
+这样就把原本隐式、不可控的叙事状态转成了可检查、可更新、可检索的结构。
+
+### 2. 把章节写作变成状态转移
+
+每章不再只是一个文本块,而是:
+
+- 从 `CBN` 进入
+- 经由若干 `CPN` 推进
+- 到 `CEN` 收束
+
+这让长篇故事天然更适合做章节级控制。
+
+### 3. 把“规划”变成动态过程
+
+不少方案只有静态 outline。`STORYTELLER` 则在每次生成中层节点时都重新参考已有状态,因此规划不是一次性产物,而是生成流程中的动态参与者。
+
+### 4. 有利于后续扩展推理
+
+由于 `NEKG` 是图结构,它理论上可以支持:
+
+- 人物关系演化追踪
+- 关键物品因果链分析
+- 支线剧情生长
+- 多角色视角整合
+
+这比纯向量检索更适合小说系统。
+
+## 局限与风险
+
+论文结果不错,但从工程落地看,这套方案也有明显成本。
+
+### 1. 流程较重
+
+它不是一次生成,而是多阶段流水线:
+
+- 高层规划
+- 中层节点规划
+- 候选节点审查
+- 正文块生成
+
+这会带来更高的:
+
+- token 成本
+- 延迟
+- 失败重试复杂度
+
+### 2. 对结构化抽象质量依赖高
+
+如果节点设计不稳定,就会出现:
+
+- 情节抽象过粗,约束力不够
+- 情节抽象过细,生成链路过长
+- 节点语义不准确,导致后续检索噪声
+
+### 3. 实体归一化难度高
+
+小说里同一个角色可能有:
+
+- 姓名
+- 称谓
+- 代词
+- 外号
+
+如果 `NEKG` 没有稳定的实体归一化与别名机制,图谱会迅速碎裂。
+
+### 4. 图谱检索可能引入噪声
+
+当实体越来越多、关系越来越密时,如何只召回“对当前候选节点真正相关”的信息,会成为系统效果上限的关键因素。
+
+## 实验结果
+
+论文报告显示,`STORYTELLER` 在人工偏好评测中平均胜率达到 `84.33%`。
+
+对比对象包括:
+
+- `GPT-4o`
+- `Qwen2-72B-Instruct`
+- `Meta-Llama-3.1-70B-Instruct`
+- `LongWriter-glm4-9b`
+- `LongWriter-llama3.1-8b`
+- `DOC v2`
+
+论文还给出人工偏好评测的代表结果:
+
+- 对 `GPT-4o` 胜率 `91%`
+- 对 `Qwen2-72B-Instruct` 胜率 `86%`
+- 对 `Meta-Llama-3.1-70B-Instruct` 胜率 `85%`
+- 对 `LongWriter-glm4-9b` 胜率 `83%`
+- 对 `LongWriter-llama3.1-8b` 胜率 `79%`
+- 对 `DOC v2` 胜率 `82%`
+
+在细分指标上,论文也报告其在以下维度领先:
+
+- Creativity
+- Coherence
+- Engagement
+- Relevance
+- Overall Performance
+
+其中 Overall 分数为 `89.4`,明显高于论文中的其他对比方法。
+
+## 对 `webnovel-writer` 的启发
+
+这篇论文对当前项目最有价值的,并不是“必须上 Neo4j”,而是它的系统拆法。
+
+### 最值得吸收的三个能力
+
+#### 1. 章节级剧情状态机
+
+当前项目如果想增强长篇稳定性,可以考虑引入类似:
+
+- 章节起点
+- 若干关键推进点
+- 章节终点
+
+不用一开始就完全复制论文里的 `CBN / CPN / CEN` 细节,但这个抽象非常适合作为章节规划层。
+
+#### 2. 角色与事件的显式状态维护
+
+论文说明,仅靠 prompt 历史无法稳定维持长篇人物一致性。
+
+对我们项目而言,更现实的做法是先实现一个轻量版 `NEKG`:
+
+- 人物卡
+- 关系边
+- 关键道具状态
+- 势力关系
+- 最近事件链
+
+第一阶段未必需要图数据库,`JSON + 索引 + 检索` 就能先跑起来。
+
+#### 3. 候选剧情审查器
+
+`Pseudo CPN Review` 的思路非常值得借鉴。
+
+可落地成一个“候选剧情检查器”,专门检查:
+
+- 是否和角色设定冲突
+- 是否和上一章结尾冲突
+- 是否违背世界规则
+- 是否重复已有桥段
+- 是否能顺利过渡到本章目标
+
+这比单纯让模型“继续写下一章”更稳定。
+
+## 建议的落地顺序
+
+如果将论文思想引入当前项目,我建议按以下顺序做简化实现:
+
+1. 先加章节级结构化节点
+2. 再加角色与关系状态存储
+3. 再加候选事件审查与修正
+4. 最后再考虑是否需要真正的图数据库
+
+原因很简单:
+
+- 论文的思想比它的具体技术栈更重要
+- 先把“结构化剧情状态”做起来,收益最大
+- 过早引入 Neo4j 可能增加工程负担
+
+## 我的判断
+
+从研究价值看,`STORYTELLER` 很像是“长篇故事生成的状态化框架”。
+
+它真正证明的是:
+
+- 大纲不够,需要中层情节节点
+- 检索不够,需要时序剧情状态和实体关系状态
+- 生成不够,需要生成中的审查修正闭环
+
+对小说系统来说,这个方向是对的,而且比纯提示词优化更有长期价值。
+
+## 结论
+
+`STORYTELLER` 不是一个靠单次 prompt 取胜的技巧,而是一套完整的叙事编排机制。
+
+它把长篇故事生成重构为:
+
+- 高层故事规划
+- 中层情节节点生成
+- 基于时间线与实体图谱的动态校验
+- 受结构约束的正文扩写
+
+对于 `webnovel-writer`,这篇论文最值得借鉴的不是照搬论文系统,而是吸收它背后的三个原则:
+
+- 故事状态要显式维护
+- 章节推进要结构化建模
+- 正文生成前要先过一致性审查
+
+如果后续要继续落地,我建议下一步直接把这篇论文转成一份“项目改造设计文档”,将 `STORYLINE / NEKG / CPN 审查器` 映射到当前仓库的模块边界。
+
+## 参考链接
+
+- ACL Anthology:<https://aclanthology.org/2025.findings-acl.1071/>
+- PDF:<https://aclanthology.org/2025.findings-acl.1071.pdf>
+- arXiv:<https://arxiv.org/abs/2506.02347>

+ 2 - 5
pytest.ini

@@ -1,7 +1,4 @@
 [pytest]
-testpaths = .claude/scripts/data_modules/tests
-pythonpath = .claude/scripts
-# 说明:
-# - 不在 CLI 里写相对路径,避免从不同工作目录运行时出现 coverage=0 的误报。
-# - 覆盖率范围由 .coveragerc 的 [run] source 控制。
+testpaths = webnovel-writer/scripts/data_modules/tests
+pythonpath = webnovel-writer/scripts
 addopts = -q --cov --cov-report=term-missing --cov-fail-under=90

+ 1 - 1
webnovel-writer/.claude-plugin/plugin.json

@@ -1,6 +1,6 @@
 {
   "name": "webnovel-writer",
-  "version": "5.5.4",
+  "version": "5.5.5",
   "description": "长篇网文创作系统(skills + agents + data chain + RAG)",
   "author": {
     "name": "lingfengQAQ"

+ 43 - 1
webnovel-writer/agents/consistency-checker.md

@@ -39,7 +39,7 @@ model: inherit
 3. `设定集/`(世界观圣经)
 4. `大纲/`(对照上下文)
 
-### 第二步: 层一致性检查
+### 第二步: 层一致性检查
 
 #### 第一层: 战力一致性(战力检查)
 
@@ -129,6 +129,38 @@ model: inherit
    → VIOLATION: Time regression without flashback marker
 ```
 
+#### 第四层: 情节结构一致性(有节点时)
+
+仅当章纲包含 `CBN/CEN/本章禁区` 字段时执行,否则跳过。
+
+**校验项**:
+- 正文是否违反本章禁区中的任何条目
+- 正文开头方向是否与 `CBN` 目标冲突
+- 正文结尾状态是否与 `CEN` 目标冲突
+- 关键节点所依赖的能力、身份、地点是否违背现有设定
+
+**危险信号** (PLOT_STRUCTURE_VIOLATION):
+```
+❌ 本章禁区违反:"萧炎不得突破到斗师",但正文描写突破场景
+   → Prohibition: 不得突破 | Actual: 正文第N段描写突破
+   → severity: high
+
+❌ CEN 方向冲突:CEN 为"药老|决定|进一步接触萧炎",但正文结尾药老直接拒绝
+   → CEN: 决定接触 | Actual ending: 拒绝并离开
+   → severity: medium
+
+❌ CBN 方向冲突:CBN 为"萧炎|抵达|迦南学院入口",正文开头却在酒楼闲聊
+   → CBN: 抵达学院 | Actual opening: 酒楼场景
+   → severity: medium
+```
+
+**严重度建议**:
+| 问题类型 | Severity |
+|---------|----------|
+| 禁区违反 | **high** |
+| CEN 方向冲突 | **medium** |
+| CBN 方向冲突 | **medium** |
+
 ### 第三步: 实体一致性检查
 
 **对所有章节中检测到的新实体**:
@@ -175,6 +207,13 @@ model: inherit
 **结论**: 发现 {Z} 处违规
 **严重时间线问题**: {count} 个(必须修复后才能继续)
 
+## 情节结构一致性(有节点时)
+| 检查项 | 结果 | 严重度 | 详情 |
+|--------|------|--------|------|
+| 本章禁区 | ✓/✗ | high | {...} |
+| CBN 方向 | ✓/✗ | medium | {...} |
+| CEN 方向 | ✓/✗ | medium | {...} |
+
 ## 新实体一致性检查
 - ✓ 与世界观一致的新实体: {count}
 - ⚠️ 不一致的实体: {count}(详见下方列表)
@@ -188,6 +227,7 @@ model: inherit
 - [战力冲突] 润色时修改第{M}章,将"破空斩"替换为筑基期可用技能
 - [地点错误] 润色时补充移动过程描述或调整地点设定
 - [时间线问题] 润色时统一时间线推算,修正矛盾
+- [结构冲突] 润色时修正禁区违反或调整章节开头/结尾方向
 - [实体冲突] 润色时确认是否为新设定或需要调整
 
 ## 综合评分
@@ -218,6 +258,7 @@ python -X utf8 "${CLAUDE_PLUGIN_ROOT:?CLAUDE_PLUGIN_ROOT is required}/scripts/we
 ❌ 接受无世界观解释的瞬移
 ❌ **降低 TIMELINE_ISSUE 严重度**(时间问题不得降级)
 ❌ **通过存在严重/高优先级时间线问题的章节**(必须修复)
+❌ **通过存在本章禁区违反的章节**
 
 ## 成功标准
 
@@ -225,4 +266,5 @@ python -X utf8 "${CLAUDE_PLUGIN_ROOT:?CLAUDE_PLUGIN_ROOT is required}/scripts/we
 - 0 个高优先级时间线问题(**倒计时错误、时间回跳、重大事件无时间推进**)
 - 所有新实体与现有世界观一致
 - 地点和时间线过渡合乎逻辑
+- 有节点时,`CBN/CEN/本章禁区` 一致性检查已完成
 - 报告为润色步骤提供具体修复建议

+ 197 - 173
webnovel-writer/agents/context-agent.md

@@ -1,21 +1,22 @@
 ---
 name: context-agent
-description: 上下文搜集Agent,内置 Context Contract,输出可被 Step 2A 直接消费的创作执行包。
+description: 上下文搜集 Agent,内置 Context Contract,输出可被 Step 2A 直接消费的创作执行包。
 tools: Read, Grep, Bash
 model: inherit
 ---
 
-# context-agent (上下文搜集Agent)
+# context-agent(上下文搜集 Agent)
 
-> **Role**: 创作执行包生成器。目标是“能直接开写”,不堆信息
-> **Philosophy**: 按需召回 + 推断补全,确保接住上章、场景清晰、留出钩子
+> **职责**:生成可直接开写的创作执行包,目标是“信息够用、约束清楚、无需补问”
+> **原则**:按需召回、推断补全、先接住上章、再锁定本章任务与章末钩子;若章纲提供结构化节点,则将其转化为本章写作节拍
 
 ## 核心参考
 
-- **Taxonomy**: `${CLAUDE_PLUGIN_ROOT}/references/reading-power-taxonomy.md`
-- **Genre Profile**: `${CLAUDE_PLUGIN_ROOT}/references/genre-profiles.md`
-- **Context Contract**: `${CLAUDE_PLUGIN_ROOT}/skills/webnovel-write/references/step-1.5-contract.md`
-- **Shared References**: `${CLAUDE_PLUGIN_ROOT}/references/shared/` 为单一事实源;如需枚举/扫描参考文件,遇到 `<!-- DEPRECATED:` 的文件一律跳过。
+- **分类参考**:`${CLAUDE_PLUGIN_ROOT}/references/reading-power-taxonomy.md`
+- **题材画像**:`${CLAUDE_PLUGIN_ROOT}/references/genre-profiles.md`
+- **执行合同**:`${CLAUDE_PLUGIN_ROOT}/skills/webnovel-write/references/step-1.5-contract.md`
+- **共享事实源**:`${CLAUDE_PLUGIN_ROOT}/references/shared/`
+  - 若需枚举共享参考文件,遇到 `<!-- DEPRECATED:` 的文件一律跳过。
 
 ## 输入
 
@@ -28,152 +29,175 @@ model: inherit
 }
 ```
 
-## 输出格式:创作执行包(Step 2A 直连)
+## 输出格式:创作执行包
 
-输出必须是单一执行包,包含 3 层:
+输出必须是一个单一执行包,包含以下 3 层内容,且三层信息必须一致。
 
-1. **任务书(8板块)**
-- 本章核心任务(目标/阻力/代价、冲突一句话、必须完成、绝对不能、反派层级)
-- 接住上章(上章钩子、读者期待、开头建议)
-- 出场角色(状态、动机、情绪底色、说话风格、红线)
-- 场景与力量约束(地点、可用能力、禁用能力)
-- **时间约束(新增)**(上章时间锚点、本章时间锚点、允许推进跨度、时间过渡要求、倒计时状态)
-- 风格指导(本章类型、参考样本、最近模式、本章建议)
-- 连续性与伏笔(时间/位置/情绪连贯;必须处理/可选伏笔)
-- 追读力策略(未闭合问题 + 钩子类型/强度、微兑现建议、差异化提示)
+### 1. 任务书(8+1 个板块)
+
+- 本章核心任务:目标、阻力、代价、核心冲突一句话、必须完成、绝对不能、反派层级
+- 接住上章:上章钩子、读者期待、开头建议
+- 出场角色:状态、动机、情绪底色、说话风格、行为红线
+- 场景与力量约束:地点、可用能力、禁用能力
+- 时间约束:上章时间锚点、本章时间锚点、允许推进跨度、时间过渡要求、倒计时状态
+- 风格指导:本章类型、参考样本、最近模式、本章建议
+- 连续性与伏笔:时间/位置/情绪连贯;必须处理与可选伏笔
+- 追读力策略:未闭合问题、钩子类型/强度、微兑现建议、差异化提示
+- 情节结构(有节点时):CBN、CPNs 序列、CEN、必须覆盖节点、本章禁区
+
+### 2. Context Contract(内置合同)
 
-2. **Context Contract(内置 Step 1.5)**
 - 目标、阻力、代价、本章变化、未闭合问题、核心冲突一句话
 - 开头类型、情绪节奏、信息密度
-- 是否过渡章(必须按大纲判定,禁止按字数判定)
-- 追读力设计(钩子类型/强度、微兑现清单、爽点模式)
+- 是否过渡章
+- 追读力设计:钩子类型/强度、微兑现清单、爽点模式
+- `plot_structure`(有节点时):`{cbn, cpns[], cen, mandatory_nodes[], prohibitions[]}`
 
-3. **Step 2A 直写提示词**
-- 章节节拍(开场触发 → 推进/受阻 → 反转/兑现 → 章末钩子)
-- 不可变事实清单(大纲事实/设定事实/承接事实)
-- 禁止事项(越级能力、无因果跳转、设定冲突、剧情硬拐)
-- 终检清单(本章必须满足项 + fail 条件)
+### 3. Step 2A 直写提示词
 
-要求:
-- 三层信息必须一致;若冲突,以“设定 > 大纲 > 风格偏好”优先。
-- 输出内容必须能直接给 Step 2A 开写,不再依赖额外补问。
+- 章节节拍:
+  - 有节点时:`CBN触发 -> CPN推进 -> CPN受阻/变化 -> ... -> CEN收束 -> 章末钩子`
+  - 无节点时:`开场触发 -> 推进/受阻 -> 反转/兑现 -> 章末钩子`
+- 不可变事实清单:大纲事实、设定事实、承接事实、长期记忆事实
+- 禁止事项:越级能力、无因果跳转、设定冲突、剧情硬拐、违反本章禁区中的任何条目(有节点时)
+- 终检清单:本章必须满足项与 fail 条件
 
----
+硬规则:
+- 若信息冲突,优先级为 `设定 > 大纲 > 长期记忆 > 风格偏好`。
+- 输出内容必须能直接交给 Step 2A 开写,不得依赖额外补问。
+- 节点结构来自章纲,`context-agent` 不得擅自生成或改写节点。
 
 ## 读取优先级与默认值
 
 | 字段 | 读取来源 | 缺失时默认值 |
 |------|---------|-------------|
 | 上章钩子 | `chapter_meta[NNNN].hook` 或 `chapter_reading_power` | `{type: "无", content: "上章无明确钩子", strength: "weak"}` |
-| 最近3章模式 | `chapter_meta` 或 `chapter_reading_power` | 空数组,不做重复检查 |
-| 上章结束情绪 | `chapter_meta[NNNN].ending.emotion` | "未知"(提示自行判断) |
-| 角色动机 | 大纲+角色状态推断 | **必须推断,无默认值** |
-| 题材Profile | `state.json → project.genre` | 默认 "shuangwen" |
-| 当前债务 | `index.db → chase_debt` | 0 |
-
-**缺失处理**:
-- 若 `chapter_meta` 不存在(如第1章),跳过“接住上章”
-- 最近3章数据不完整时,只用现有数据做差异化检查
+| 最近 3 章模式 | `chapter_meta` 或 `chapter_reading_power` | 空数组 |
+| 上章结束情绪 | `chapter_meta[NNNN].ending.emotion` | `未知` |
+| 角色动机 | 大纲 + 角色状态推断 | 必须推断,无默认值 |
+| 题材画像 | `state.json -> project.genre` | `shuangwen` |
+| 当前债务 | `index.db -> chase_debt` | `0` |
+
+缺失处理:
+- 若 `chapter_meta` 不存在,跳过“接住上章”
+- 最近 3 章数据不完整时,只用现有数据做差异化检查
 - 若 `plot_threads.foreshadowing` 缺失或非列表:
-  - 视为“当前无结构化伏笔数据”,第 7 板块输出空清单并显式标注“数据缺失,需人工补录”
-  - 禁止静默跳过第 7 板块
-
-**章节编号规则**: 4位数字,如 `0001`, `0099`, `0100`
+  - 第 7 板块仍必须输出;
+  - 显式标注“结构化伏笔数据缺失,需人工补录”;
+  - 禁止静默跳过伏笔板块。
+- 若章纲无结构化节点字段:
+  - 跳过“情节结构”板块;
+  - 使用旧版节拍生成逻辑;
+  - 不阻断流程。
 
----
+章节编号统一使用 4 位数,如 `0001`、`0099`、`0100`。
 
 ## 关键数据来源
 
-- `state.json`: 进度、主角状态、strand_tracker、chapter_meta、project.genre、plot_threads.foreshadowing
-- `index.db`: 实体/别名/关系/状态变化/override_contracts/chase_debt/chapter_reading_power
-- `.webnovel/summaries/ch{NNNN}.md`: 章节摘要(含钩子/结束状态)
-- `.webnovel/context_snapshots/`: 上下文快照(优先复用)
+- `state.json`:进度、主角状态、`strand_tracker`、`chapter_meta`、`project.genre`、`plot_threads.foreshadowing`
+- `index.db`:实体、别名、关系、状态变化、覆盖合同、追读力债务
+- `.webnovel/summaries/ch{NNNN}.md`:章节摘要
+- `.webnovel/context_snapshots/`:上下文快照,优先复用
+- `.webnovel/memory_scratchpad.json`:当前长期记忆事实存储
 - `大纲/` 与 `设定集/`
 
-**钩子数据来源说明**:
-- **章纲的"钩子"字段**:本章应设置的章末钩子(规划用)
-- **chapter_meta[N].hook**:本章实际设置的钩子(执行结果)
-- **context-agent 读取**:chapter_meta[N-1].hook 作为"上章钩子"
-- **数据流**:章纲规划 → 写作实现 → 写入 chapter_meta → 下章读取
+钩子数据说明:
+- 章纲中的“钩子”字段:规划中的章末钩子
+- `chapter_meta[N].hook`:实际写入后的章末钩子
+- 本 Agent 读取 `chapter_meta[N-1].hook` 作为“上章钩子”
 
----
-
-## 执行流程(精简版)
+## 执行流程
 
-### Step -1: CLI 入口与脚本目录校验(必做)
+### Step 1:校验脚本入口与项目根目录
 
-为避免 `PYTHONPATH` / `cd` / 参数顺序导致的隐性失败,所有 CLI 调用统一走:
-- `${SCRIPTS_DIR}/webnovel.py`
+所有 CLI 调用统一走 `${SCRIPTS_DIR}/webnovel.py`。
 
 ```bash
-# 仅使用 CLAUDE_PLUGIN_ROOT,避免多路径探测带来的误判
 if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
   echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/scripts" >&2
   exit 1
 fi
-SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 
-# 建议先确认解析出的 project_root,避免写到错误目录
+SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" where
 ```
 
-### Step 0: ContextManager 快照优先
+要求:
+- `project_root` 必须能解析到真实书项目根。
+- 任一校验失败立即中断,不进入后续步骤。
+
+### Step 2:优先读取 ContextManager 快照
+
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" context -- --chapter {NNNN}
 ```
 
-### Step 0.5: Context Contract 上下文包(内置)
+要求:
+- 若已有可用快照,优先复用快照中的稳定事实。
+- 快照与最新大纲冲突时,以最新大纲为准。
+
+### Step 3:读取上下文合同包
+
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" extract-context --chapter {NNNN} --format json
 ```
 
-- 必须读取:`writing_guidance.guidance_items`
-- 推荐读取:`reader_signal` 与 `genre_profile.reference_hints`
-- 条件读取:`rag_assist`(当 `invoked=true` 且 `hits` 非空时,必须提炼成可执行约束,禁止只贴检索命中)
+必须读取:
+- `writing_guidance.guidance_items`
+
+推荐读取:
+- `reader_signal`
+- `genre_profile.reference_hints`
 
-### Step 0.6: 时间线读取(新增,必做)
+条件读取:
+- `rag_assist.invoked=true` 且 `hits` 非空时,必须把命中内容提炼成可执行约束,禁止原样粘贴检索结果。
+
+### Step 4:读取时间线与长期记忆
 
 先确定 `{volume_id}`:
-- 优先读取 `state.json` 中当前卷信息(如有)
-- 若缺失,则从 `大纲/总纲.md` 的章节范围反推 `{NNNN}` 所在卷
+- 优先读取 `state.json` 当前卷信息
+- 若缺失,则从 `大纲/总纲.md` 的章节范围反推
+
+读取本卷时间线:
 
-读取本卷时间线表:
 ```bash
 cat "{project_root}/大纲/第{volume_id}卷-时间线.md"
 ```
 
-从章纲提取本章时间字段:
-- `时间锚点`:本章发生的具体时间
-- `章内时间跨度`:本章覆盖的时间长度
-- `与上章时间差`:与上章的时间间隔
-- `倒计时状态`:若有倒计时事件的推进情况
-
-从上章 chapter_meta 或章纲提取:
-- 上章结束时间锚点
-- 上章倒计时状态
-
-生成时间约束输出(必须包含在任务书第 5 板块):
-```markdown
-## 时间约束
-- 上章时间锚点: {末世第3天 黄昏}
-- 本章时间锚点: {末世第4天 清晨}
-- 与上章时间差: {跨夜}
-- 本章允许推进: 最大 {章内时间跨度}
-- 时间过渡要求: {若跨夜/跨日,需补写的过渡句}
-- 倒计时状态: {物资耗尽 D-5 → D-4 / 无}
+读取长期记忆:
+
+```bash
+cat "{project_root}/.webnovel/memory_scratchpad.json"
 ```
 
-**时间约束硬规则**:
-- 若 `与上章时间差` 为"跨夜"或"跨日",必须在任务书中标注"需补写时间过渡"
-- 若存在倒计时事件,必须校验推进是否正确(D-N 只能变为 D-(N-1),不可跳跃)
-- 时间锚点不得回跳(除非明确标注为闪回章节)
+必须提取:
+- 本章时间锚点、章内时间跨度、与上章时间差、倒计时状态
+- 与当前章节直接相关的长期事实:`timeline_events`、`world_rules`、`open_loops`、`reader_promises`
+
+时间硬规则:
+- `跨夜` 或 `跨日` 必须标注“需补写时间过渡”。
+- 倒计时只能按有效步长推进,不得跳跃。
+- 时间锚点不得回跳,除非明确标注闪回。
+
+长期记忆硬规则:
+- 只提炼与本章直接相关的事实,禁止整库搬运。
+- `open_loops` 与 `reader_promises` 命中时,必须进入任务书或终检清单。
+
+### Step 5:读取大纲与运行状态
 
-### Step 1: 读取大纲与状态
 - 大纲:`大纲/卷N/第XXX章.md` 或 `大纲/第{卷}卷-详细大纲.md`
-  - 必须优先提取并写入任务书:目标/阻力/代价/反派层级/本章变化/章末未闭合问题/钩子(若存在)
-- `state.json`:progress / protagonist_state / chapter_meta / project.genre
+- 状态:`state.json`
+
+必须提取并写入任务书:
+- 目标、阻力、代价、反派层级、本章变化、章末未闭合问题、钩子
+
+若章纲包含 `CBN/CPNs/CEN/必须覆盖节点/本章禁区` 字段:
+- 额外提取并组装为“情节结构”板块
+- 将节点映射为 `plot_structure`
+- 缺失时跳过,不阻断
+
+### Step 6:读取追读力、债务与模式数据
 
-### Step 2: 追读力与债务(按需)
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-recent-reading-power --limit 5
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-pattern-usage-stats --last-n 20
@@ -181,88 +205,88 @@ python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-ho
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-debt-summary
 ```
 
-### Step 3: 实体与最近出场 + 伏笔读取
+要求:
+- 仅用于差异化建议、追读力设计、债务提醒。
+- 不得让“追读力偏好”覆盖大纲主任务。
+
+### Step 7:读取实体、出场记录与伏笔
+
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-core-entities
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index recent-appearances --limit 20
 ```
 
-- 从 `state.json` 读取:
-  - `progress.current_chapter`
-  - `plot_threads.foreshadowing`(主路径)
-- 缺失降级:
-  - 若 `plot_threads.foreshadowing` 不存在或类型错误,置为空数组并打标 `foreshadowing_data_missing=true`
-- 对每条伏笔至少提取:
-  - `content`
-  - `planted_chapter`
-  - `target_chapter`
-  - `resolved_chapter`
-  - `status`
-- 回收判定优先级:
-  - 若 `resolved_chapter` 非空,直接视为已回收并排除(即使 `status` 文案异常)
-  - 否则按 `status` 判定是否已回收
-- 生成排序键:
-  - `remaining = target_chapter - current_chapter`(若缺失则记为 `null`)
-  - 二次排序:`planted_chapter` 升序(更早埋设优先)
-  - 三次排序:`content` 字典序(确保稳定)
-- 输出到第 7 板块时,按 `remaining` 升序列出。
-
-### Step 4: 摘要与推断补全
+伏笔处理规则:
+- 主路径:`state.json -> plot_threads.foreshadowing`
+- 缺失时置为空数组,并标记 `foreshadowing_data_missing=true`
+- 每条伏笔至少提取:`content`、`planted_chapter`、`target_chapter`、`resolved_chapter`、`status`
+- 若 `resolved_chapter` 非空,直接视为已回收并排除
+- 排序键:
+  - `remaining = target_chapter - current_chapter`
+  - 再按 `planted_chapter` 升序
+  - 再按 `content` 字典序
+
+第 7 板块输出规则:
+- `必须处理`:`remaining <= 5` 或已超期
+- `可选伏笔`:最多 5 条
+- 若数据缺失,必须显式说明
+
+### Step 8:读取摘要并做推断补全
+
 - 优先读取 `.webnovel/summaries/ch{NNNN-1}.md`
-- 若缺失,降级为章节正文前 300-500 字概述
-- 推断规则:
-  - 动机 = 角色目标 + 当前处境 + 上章钩子压力
-  - 情绪底色 = 上章结束情绪 + 事件走向
-  - 可用能力 = 当前境界 + 近期获得 + 设定禁用项
-
-### Step 5: 组装创作执行包(任务书 + Context Contract + 直写提示词)
-输出可直接供 Step 2A 消费的单一执行包,不拆分独立 Step 1.5。
-
-- 第 7 板块必须包含“伏笔优先级清单”:
-  - `必须处理(本章优先)`:`remaining <= 5` 或已超期(`remaining < 0`),全部列出不截断
-  - `可选伏笔(可延后)`:最多 5 条
-- 第 7 板块生成规则(统一口径):
-  - 仅纳入未回收伏笔(见 Step 3 回收判定)
-  - 主排序按 `remaining` 升序,`remaining=null` 放末尾
-  - 若 `必须处理` 超过 3 条:前 3 条标记“最高优先”,其余标记“本章仍需处理”
-  - 若 `可选伏笔` 超过 5 条:展示前 5 条并标注“其余 N 条可选伏笔已省略”
-  - 若 `foreshadowing_data_missing=true`:明确输出“结构化伏笔数据缺失,当前清单仅供占位”
-
-Context Contract 必须字段(不可缺):
-- `目标` / `阻力` / `代价` / `本章变化` / `未闭合问题`
-- `核心冲突一句话`
-- `开头类型` / `情绪节奏` / `信息密度`
-- `是否过渡章`
-- `追读力设计`
-
-### Step 6: 逻辑红线校验(输出前强制)
-对执行包做一致性自检,任一 fail 则回到 Step 5 重组:
-
-- 红线1:不可变事实冲突(大纲关键事件、设定规则、上章既有结果)
-- 红线2:时空跳跃无承接(地点/时间突变且无过渡)
-- 红线3:能力或信息无因果来源(突然会/突然知道)
-- 红线4:角色动机断裂(行为与近期目标明显冲突且无触发)
-- 红线5:合同与任务书冲突(例如“过渡章=true”却要求高强度高潮兑现)
-- **红线6:时间逻辑错误**(时间回跳、倒计时跳跃、大跨度无过渡)
+- 缺失时退化为上一章正文前 300-500 字概述
 
-通过标准
-- 红线 fail 数 = 0
-- 执行包内包含“不可变事实清单 + 章节节拍 + 终检清单 + 时间约束”
-- Step 2A 在不补问情况下可直接起草正文
+推断规则:
+- 动机 = 角色目标 + 当前处境 + 上章钩子压力
+- 情绪底色 = 上章结束情绪 + 事件走向
+- 可用能力 = 当前境界 + 近期获得 + 设定禁用项
 
----
+要求:
+- 角色动机和情绪不能留空。
+- 推断结果必须可落实到“出场角色”板块与正文终检。
+
+### Step 9:组装创作执行包
+
+输出单一执行包,包含:
+- 任务书
+- Context Contract
+- Step 2A 直写提示词
+
+硬要求:
+- 任务书必须包含 8+1 个板块,且含“时间约束”。
+- “不可变事实清单”必须纳入长期记忆事实。
+- `open_loops` 和 `reader_promises` 若与本章有关,必须进入“连续性与伏笔”或“终检清单”。
+- 若 `必须处理` 伏笔超过 3 条:前 3 条标记“最高优先”,其余标记“本章仍需处理”。
+- 有节点时,必须把 `plot_structure` 纳入合同与节拍映射。
+
+### Step 10:执行红线校验并输出
+
+输出前必须做一致性自检,任一 fail 都回到 Step 9 重组:
+
+- 红线 1:不可变事实冲突
+- 红线 2:时空跳跃无承接
+- 红线 3:能力或信息无因果来源
+- 红线 4:角色动机断裂
+- 红线 5:合同与任务书冲突
+- 红线 6:时间逻辑错误
+- 红线 7:长期记忆事实被遗漏或写反
+- 红线 8:情节结构节点与任务书/合同存在方向冲突(有节点时)
+
+通过标准:
+- fail 数 = 0
+- 执行包内包含:不可变事实清单、章节节拍、终检清单、时间约束
+- Step 2A 无需补问即可直接起草正文
 
 ## 成功标准
 
-1. ✅ 创作执行包可直接驱动 Step 2A(无需补问)
-2. ✅ 任务书包含 8 个板块(含时间约束)
-3. ✅ 上章钩子与读者期待明确(若存在)
-4. ✅ 角色动机/情绪为推断结果(非空)
-5. ✅ 最近模式已对比,给出差异化建议
-6. ✅ 章末钩子建议类型明确
-7. ✅ 反派层级已注明(若大纲提供)
-8. ✅ 第 7 板块已基于 `plot_threads.foreshadowing` 按紧急度排序输出
-9. ✅ Context Contract 字段完整且与任务书一致
-10. ✅ 逻辑红线校验通过(fail=0)
-11. ✅ **时间约束板块完整**(上章时间锚点、本章时间锚点、允许推进跨度、过渡要求、倒计时状态)
-12. ✅ **时间逻辑红线通过**(无回跳、无倒计时跳跃、大跨度有过渡要求)
+1. 创作执行包可直接驱动 Step 2A。
+2. 任务书包含 8+1 个板块,且时间约束完整。
+3. 上章钩子与读者期待明确。
+4. 角色动机与情绪为非空推断结果。
+5. 最近模式已对比,并给出差异化建议。
+6. 第 7 板块已按紧急度输出伏笔清单。
+7. Context Contract 字段完整且与任务书一致。
+8. 长期记忆事实已被读取,并进入不可变事实或终检清单。
+9. 时间逻辑红线通过。
+10. Step 2A 可在不补问的情况下直接开写。
+11. 有节点时,情节结构板块已完整纳入执行包,节拍映射清晰。

+ 45 - 0
webnovel-writer/agents/continuity-checker.md

@@ -153,6 +153,39 @@ vs.
 - **中等**(情节调整): 需标记并确认
 - **重大**(核心冲突变化): 必须标记 `<deviation reason="..."/>` 并说明
 
+### 第三步-B: 情节节点覆盖检查(有节点时)
+
+仅当章纲包含 `CBN/CPNs/CEN/必须覆盖节点` 字段时执行,否则跳过。
+
+**检查项**:
+1. **CBN 承接**:正文开头 1/3 是否体现了 CBN 描述的起始状态或事件方向
+2. **CEN 落地**:正文结尾 1/3 是否体现了 CEN 描述的结束状态或事件方向
+3. **必须节点覆盖**:`必须覆盖节点` 中列出的 CPN 在正文中是否有对应情节
+4. **可选节点覆盖**:非必须 CPN 的覆盖情况(仅统计,不作为 fail 主依据)
+
+判定方式:
+- 语义方向匹配,不要求逐字对应
+- 允许叙述扩展、合并表达和场景重排
+- 不允许跳过 CBN/CEN 或完全缺失必须节点
+
+**覆盖评级**:
+- **A**: CBN+CEN 方向吻合,所有必须节点已覆盖
+- **B**: CBN+CEN 基本吻合,1 个必须节点仅弱覆盖
+- **C**: CBN 或 CEN 偏离,或 2 个以上必须节点未覆盖
+- **F**: CBN+CEN 均偏离,或核心必须节点完全缺失
+
+**报告格式**:
+```markdown
+## 情节节点覆盖
+| 节点 | 类型 | 要求 | 正文对应 | 状态 |
+|------|------|------|---------|------|
+| 萧炎 \| 抵达 \| 迦南学院入口 | CBN | 必须 | 第1段:抵达描写 | ✓ 覆盖 |
+| 萧炎 \| 展示 \| 异火控制力 | CPN | 必须 | 第4-5段:考核表现 | ✓ 覆盖 |
+| 药老 \| 对萧炎产生 \| 明确兴趣 | CEN | 必须 | 末段:态度转变 | ✓ 覆盖 |
+
+**节点覆盖评级**: A
+```
+
 ### 第四步: 拖沓检查(拖沓检查)
 
 **识别拖沓段落**:
@@ -202,6 +235,15 @@ vs.
 
 **伏笔健康度**: {X} 已回收, {Y} 待处理, {Z} 有风险
 
+## 情节节点覆盖(有节点时)
+| 节点 | 类型 | 要求 | 正文对应 | 状态 |
+|------|------|------|---------|------|
+| {CBN} | CBN | 必须 | {...} | ✓/✗ |
+| {CPN1} | CPN | 必须 | {...} | ✓/△/✗ |
+| {CEN} | CEN | 必须 | {...} | ✓/✗ |
+
+**节点覆盖评级**: {A/B/C/F}
+
 ## 逻辑一致性
 | 章节 | 问题 | 类型 | 严重度 |
 |------|------|------|--------|
@@ -226,6 +268,7 @@ vs.
 3. **解决逻辑矛盾**: 第{M}章修改"从未见过"为"很少见到"
 4. **提及休眠线索**: 血煞门入侵线索建议第47章再次提及
 5. **压缩拖沓段落**: 第{N}-{M}章赶路场景合并为1章
+6. **修复节点覆盖问题**: 补写缺失的必须节点或调整结尾落点
 
 ## 综合评分
 **连贯性总评**: {流畅/可接受/生硬/断裂}
@@ -239,6 +282,7 @@ vs.
 ❌ 忽略遗忘伏笔(10+ 章休眠)
 ❌ 接受突兀的场景转换(F 级)
 ❌ 忽视情节漏洞和前后矛盾
+❌ 有节点覆盖 `F` 级评定却未在修复建议中提及
 
 ## 成功标准
 
@@ -248,3 +292,4 @@ vs.
 - 0 个重大逻辑漏洞
 - 大纲偏差已正确标记
 - 报告指出需修复的具体章节
+- 有节点时,节点覆盖评级 ≥ B

+ 134 - 160
webnovel-writer/agents/data-agent.md

@@ -1,21 +1,22 @@
 ---
 name: data-agent
-description: 数据处理Agent,负责 AI 实体提取、场景切片、索引构建,并记录钩子/模式/结束状态与章节摘要
+description: 数据处理 Agent,负责实体提取、摘要回写、长期记忆提炼、索引构建与观测记录
 tools: Read, Write, Bash
 model: inherit
 ---
 
-# data-agent (数据处理Agent)
+# data-agent(数据处理 Agent)
 
-> **职责**: 智能数据工程师,负责从章节正文中提取结构化信息并写入数据链。
->
-> **原则**: AI驱动提取,智能消歧 - 用语义理解替代正则匹配,用置信度控制质量。
+> **职责**:从章节正文提取结构化信息,写回状态、索引、摘要、长期记忆与观测日志。
+> **原则**:AI 驱动提取、语义消歧、一次处理、多库同步、失败最小隔离。
 
-**命令示例即最终准则**:本文档中的所有 CLI 命令示例已与当前仓库真实接口对齐。脚本调用方式以本文档示例为准;命令失败时查错误日志定位问题,不去大范围翻源码学习调用方式。
+**命令示例即最终准则**:本文档中的 CLI 调用方式已与当前仓库接口对齐。命令失败时优先查日志,不去翻源码猜调用方式。
 
-**当前约定**:
-- 章节摘要不再追加到正文,改为 `.webnovel/summaries/ch{NNNN}.md`
-- 在 state.json 写入 `chapter_meta`(钩子/模式/结束状态)
+## 当前约定
+
+- 章节摘要写入 `.webnovel/summaries/ch{NNNN}.md`
+- `state.json` 写入 `chapter_meta`
+- 长期记忆提取结果写入 `memory_facts`,再交由写入器同步到 `.webnovel/memory_scratchpad.json`
 
 ## 输入
 
@@ -30,13 +31,19 @@ model: inherit
 }
 ```
 
-`chapter_file` 必须传入实际章节文件路径。若详细大纲已有章节名,优先使用带标题文件名;旧的 `正文/第0100章.md` 仍兼容。
+要求:
+- `chapter_file` 必须传入真实章节文件路径。
+- 若详细大纲已有标题,优先使用 `正文/第0100章-章节标题.md`。
+- 旧格式 `正文/第0100章.md` 仍兼容。
+
+## 主要写入位置
 
-**重要**: 所有数据写入 `{project_root}/.webnovel/` 目录:
-- index.db → 实体、别名、状态变化、关系、章节索引 (SQLite)
-- state.json → 进度、配置、节奏追踪 + chapter_meta
-- vectors.db → RAG 向量 (SQLite)
-- summaries/ → 章节摘要文件
+- `.webnovel/index.db`:实体、别名、关系、状态变化、章节索引
+- `.webnovel/state.json`:进度、主角状态、节奏追踪、`chapter_meta`
+- `.webnovel/vectors.db`:RAG 向量索引
+- `.webnovel/summaries/`:章节摘要文件
+- `.webnovel/memory_scratchpad.json`:长期记忆暂存事实
+- `.webnovel/observability/data_agent_timing.jsonl`:分步耗时日志
 
 ## 输出
 
@@ -54,20 +61,23 @@ model: inherit
   "relationships_new": [
     {"from": "xiaoyan", "to": "hongyi_girl", "type": "相识", "description": "初次见面"}
   ],
+  "memory_facts": {
+    "timeline_events": [],
+    "world_rules": [],
+    "open_loops": [],
+    "reader_promises": []
+  },
   "scenes_chunked": 4,
-  "uncertain": [
-    {"mention": "那位前辈", "candidates": [{"type": "角色", "id": "yaolao"}, {"type": "角色", "id": "elder_zhang"}], "confidence": 0.6}
-  ],
-  "warnings": []
+  "uncertain": [],
+  "warnings": [],
+  "timing_ms": {},
+  "bottlenecks_top3": []
 }
 ```
 
 ## 执行流程
 
-### Step -1: CLI 入口与脚本目录校验(必做)
-
-为避免 `PYTHONPATH` / `cd` / 参数顺序导致的隐性失败,所有 CLI 调用统一走:
-- `${SCRIPTS_DIR}/webnovel.py`
+### Step 1:校验脚本入口与项目根目录
 
 ```bash
 export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT:?CLAUDE_PLUGIN_ROOT is required}/scripts"
@@ -75,62 +85,66 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" pref
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" where
 ```
 
-### Step A: 加载上下文(SQL 查询)
+要求:
+- `preflight` 必须通过。
+- 无法解析项目根或脚本目录时立即中断。
 
-使用 Read 工具读取章节正文:
-- 章节正文: 实际章节文件路径(优先 `正文/第0100章-章节标题.md`,旧格式 `正文/第0100章.md` 仍兼容)
+### Step 2:加载正文与索引上下文
 
-使用 Bash 工具从 index.db 查询已有实体:
- ```bash
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-core-entities
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-aliases --entity "xiaoyan"
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index recent-appearances --limit 20
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-by-alias --alias "萧炎"
-  ```
+使用 `Read` 读取章节正文,使用 `Bash` 读取已有实体与最近出场记录。
 
-### Step B: AI 实体提取
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-core-entities
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index recent-appearances --limit 20
+```
 
-**Data Agent 直接执行** (无需调用外部 LLM)。
+按需读取:
 
-### Step C: 实体消歧处理
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-aliases --entity "xiaoyan"
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-by-alias --alias "萧炎"
+```
 
-**置信度策略**:
 
-| 置信度范围 | 处理方式 |
-|-----------|---------|
-| > 0.8 | 自动采用,无需确认 |
-| 0.5 - 0.8 | 采用建议值,记录 warning |
-| < 0.5 | 标记待人工确认,不自动写入 |
+### Step 3:执行实体提取与语义消歧
 
-### Step D: 写入存储
+由 Data Agent 在同一轮上下文内直接完成,不额外调用独立 LLM Agent。
 
- **写入 index.db (实体/别名/状态变化/关系)**:
- ```bash
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index upsert-entity --data '{...}'
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index register-alias --alias "红衣女子" --entity "hongyi_girl" --type "角色"
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index record-state-change --data '{...}'
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index upsert-relationship --data '{...}'
- ```
+置信度规则:
+- `> 0.8`:自动采用
+- `0.5 - 0.8`:采用建议值,并记录 warning
+- `< 0.5`:标记待人工确认,不自动写入
 
- **更新精简版 state.json**:
- ```bash
-  python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" state process-chapter --chapter 100 --data '{...}'
- ```
+### Step 4:写入实体、状态与关系数据
 
-写入内容:
-- 更新 `progress.current_chapter`
-- 更新 `protagonist_state`
-- 更新 `strand_tracker`
-- 更新 `disambiguation_warnings/pending`
-- **新增 `chapter_meta`**(钩子/模式/结束状态)
+写入 `index.db`:
 
-### Step E: 生成章节摘要文件(新增)
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index upsert-entity --data '{...}'
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index register-alias --alias "红衣女子" --entity "hongyi_girl" --type "角色"
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index record-state-change --data '{...}'
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index upsert-relationship --data '{...}'
+```
+
+更新 `state.json`:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" state process-chapter --chapter 100 --data '{...}'
+```
 
-**输出路径**: `.webnovel/summaries/ch{NNNN}.md`
+必须写入:
+- `progress.current_chapter`
+- `protagonist_state`
+- `strand_tracker`
+- `disambiguation_warnings/pending`
+- `chapter_meta`
 
-**章节编号规则**: 4位数字,如 `0001`, `0099`, `0100`
+### Step 5:生成章节摘要文件
+
+输出路径:`.webnovel/summaries/ch{NNNN}.md`
+
+摘要格式:
 
-**摘要文件格式**:
 ```markdown
 ---
 chapter: 0099
@@ -153,12 +167,28 @@ hook_strength: "strong"
 {下章衔接,30字}
 ```
 
-### Step F: AI 场景切片
+### Step 6:提取长期记忆事实
+
+在同一轮 Data Agent 上下文中提取以下结构,并写入 `memory_facts`:
+- `timeline_events`
+- `world_rules`
+- `open_loops`
+- `reader_promises`
+
+约束:
+- 不新增额外 LLM 调用。
+- 不创建独立 extractor Agent。
+- 只提炼“可跨章复用”的长期事实,不混入临时工作记忆。
+- 提取结果必须交由 `memory/writer.py` 写入 `.webnovel/memory_scratchpad.json`。
 
-- 按地点/时间/视角切分场景
-- 每个场景生成摘要 (50-100字)
+### Step 7:执行场景切片
 
-### Step G: 向量嵌入
+- 按地点、时间、视角切分场景
+- 每个场景生成 50-100 字摘要
+
+### Step 8:写入 RAG 向量与风格样本
+
+向量索引:
 
 ```bash
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" rag index-chapter \
@@ -167,98 +197,44 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" rag
   --summary "本章摘要文本"
 ```
 
-**父子索引规则**:
-- 父块: `chunk_type='summary'`, `chunk_id='ch0100_summary'`
-- 子块: `chunk_type='scene'`, `chunk_id='ch0100_s{scene_index}'`, `parent_chunk_id='ch0100_summary'`
-- `source_file`:
-  - summary: `summaries/ch0100.md`
-  - scene: `{chapter_file}#scene_{scene_index}`
+父子索引规则:
+- 父块:`chunk_type='summary'`,`chunk_id='ch0100_summary'`
+- 子块:`chunk_type='scene'`,`chunk_id='ch0100_s{scene_index}'`
 
-### Step H: 风格样本评估
-
-```python
-if review_score >= 80:
-    extract_style_candidates(chapter_content)
-```
+风格样本提取仅在 `review_score >= 80` 时执行:
 
 ```bash
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" style extract --chapter 100 --score 85 --scenes '[...]'
 ```
 
-### Step I: 债务利息计算
-
-**默认不自动触发**。仅在“开启债务追踪”或用户明确要求时执行:
- ```bash
- python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index accrue-interest --current-chapter {chapter}
- ```
-
-此步骤会:
-- 对所有 `status='active'` 的债务计算利息(每章 10%)
-- 将逾期债务标记为 `status='overdue'`
-- 记录利息事件到 `debt_events` 表
-
-### Step J: 生成处理报告(含性能日志)
-
-**必须记录分步耗时**(用于定位慢点):
-- A 加载上下文
-- B AI 实体提取
-- C 实体消歧
-- D 写入 state/index
-- E 写入章节摘要
-- F AI 场景切片
-- G RAG 向量索引
-- H 风格样本评估(若跳过写 0)
-- I 债务利息(若跳过写 0)
-- TOTAL 总耗时
-
-**性能日志落盘(新增,必做)**:
-- 脚本自动写入:`.webnovel/observability/data_agent_timing.jsonl`
-- Data Agent 报告中仍需返回:`timing_ms` + `bottlenecks_top3`
-- 规则:`bottlenecks_top3` 始终按耗时降序返回;当 `TOTAL > 30000ms` 时,需在报告文字部分附加原因说明。
-
-观测日志说明:
-- `call_trace.jsonl`:外层流程调用链(agent 启动、排队、环境探测等系统开销)。
-- `data_agent_timing.jsonl`:Data Agent 内部各子步骤耗时。
-- 当外层总耗时远大于内层 timing 之和时,默认先归因为 agent 启动与环境探测开销,不误判为正文或数据处理慢。
+### Step 9:按需计算债务利息
 
-```json
-{
-  "chapter": 100,
-  "entities_appeared": 5,
-  "entities_new": 1,
-  "state_changes": 1,
-  "relationships_new": 1,
-  "scenes_chunked": 4,
-  "uncertain": [
-    {"mention": "那位前辈", "candidates": [{"type": "角色", "id": "yaolao"}, {"type": "角色", "id": "elder_zhang"}], "adopted": "yaolao", "confidence": 0.6}
-  ],
-  "warnings": [
-    "中置信度匹配: 那位前辈 → yaolao (confidence: 0.6)"
-  ],
-  "errors": [],
-  "timing_ms": {
-    "A_load_context": 120,
-    "B_entity_extract": 18500,
-    "C_disambiguation": 210,
-    "D_state_index_write": 430,
-    "E_summary_write": 90,
-    "F_scene_chunking": 6200,
-    "G_rag_index": 2800,
-    "H_style_sample": 150,
-    "I_debt_interest": 0,
-    "TOTAL": 28500
-  },
-  "bottlenecks_top3": [
-    {"step": "B_entity_extract", "elapsed_ms": 18500, "ratio": 64.9},
-    {"step": "F_scene_chunking", "elapsed_ms": 6200, "ratio": 21.8},
-    {"step": "G_rag_index", "elapsed_ms": 2800, "ratio": 9.8}
-  ]
-}
+默认不自动触发,仅在用户明确要求或已开启债务追踪时执行:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index accrue-interest --current-chapter {chapter}
 ```
 
----
+### Step 10:生成处理报告与观测日志
 
-## 接口规范:chapter_meta (state.json)
+必须记录分步耗时:
+- Step 2:加载正文与索引上下文
+- Step 3:实体提取与消歧
+- Step 4:写入实体与状态
+- Step 5:写入章节摘要
+- Step 6:长期记忆提取
+- Step 7:场景切片
+- Step 8:向量与风格样本
+- Step 9:债务利息
+- TOTAL:总耗时
+
+观测规则:
+- 脚本自动写入 `.webnovel/observability/data_agent_timing.jsonl`
+- 返回结果中仍需包含 `timing_ms` 与 `bottlenecks_top3`
+- `bottlenecks_top3` 必须按耗时降序
+- `TOTAL > 30000ms` 时,必须附加原因说明
+
+## 接口规范:chapter_meta
 
 ```json
 {
@@ -285,15 +261,13 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" styl
 }
 ```
 
----
-
 ## 成功标准
 
-1. ✅ 所有出场实体被正确识别(准确率 > 90%)
-2. ✅ 状态变化被正确捕获(准确率 > 85%)
-3. ✅ 消歧结果合理(高置信度 > 80%)
-4. ✅ 场景切片数量合理(通常 3-6 个/章)
-5. ✅ 向量成功存入数据库
-6. ✅ 章节摘要文件生成成功
-7. ✅ chapter_meta 写入 state.json
-8. ✅ 输出格式为有效 JSON
+1. 出场实体识别完整且消歧结果合理。
+2. 状态变化、关系变化已正确落库。
+3. `state.json` 与 `chapter_meta` 已更新。
+4. `.webnovel/summaries/ch{NNNN}.md` 已生成。
+5. `memory_facts` 已产出并写入 `.webnovel/memory_scratchpad.json`。
+6. 场景切片与向量索引成功写入。
+7. `review_score >= 80` 时已按规则提取风格样本。
+8. 观测日志已写入,输出为有效 JSON。

+ 93 - 0
webnovel-writer/scripts/chapter_outline_loader.py

@@ -6,6 +6,7 @@ from __future__ import annotations
 import json
 import re
 from pathlib import Path
+from typing import Any, Dict
 
 try:
     from chapter_paths import volume_num_for_chapter
@@ -127,3 +128,95 @@ def load_chapter_outline(project_root: Path, chapter_num: int, max_chars: int |
     if max_chars and len(outline) > max_chars:
         return outline[:max_chars] + "\n...(已截断)"
     return outline
+
+_PLOT_SECTION_FIELD_MAP = {
+    "cbn": "cbn",
+    "cpns": "cpns",
+    "cen": "cen",
+    "必须覆盖节点": "mandatory_nodes",
+    "本章禁区": "prohibitions",
+}
+
+
+def _clean_plot_line(line: str) -> str:
+    text = str(line or "").strip()
+    text = re.sub(r"^[\-\*•]+\s*", "", text)
+    text = re.sub(r"^\d+[\.、]\s*", "", text)
+    return text.strip()
+
+
+def _append_plot_value(target: Dict[str, Any], field: str, value: str) -> None:
+    value = _clean_plot_line(value)
+    if not value:
+        return
+
+    if field in {"cpns", "mandatory_nodes", "prohibitions"}:
+        target.setdefault(field, [])
+        candidates = [value]
+        if field in {"mandatory_nodes", "prohibitions"}:
+            split_values = [part.strip() for part in re.split(r"[、,,;;|]+", value) if part.strip()]
+            if split_values:
+                candidates = split_values
+        for item in candidates:
+            if item not in target[field]:
+                target[field].append(item)
+        return
+
+    if field not in target:
+        target[field] = value
+
+
+def parse_chapter_plot_structure(outline_text: str) -> Dict[str, Any]:
+    text = str(outline_text or "")
+    if not text or text.startswith("⚠️"):
+        return {}
+
+    structure: Dict[str, Any] = {}
+    current_field = ""
+
+    for raw_line in text.splitlines():
+        stripped = raw_line.strip()
+        if not stripped:
+            current_field = ""
+            continue
+        if re.match(r"^#{1,6}\s*第\s*\d+\s*章", stripped):
+            current_field = ""
+            continue
+
+        cleaned = _clean_plot_line(stripped)
+        matched_field = ""
+        matched_value = ""
+        for label, field in _PLOT_SECTION_FIELD_MAP.items():
+            match = re.match(rf"^{re.escape(label)}\s*[::]\s*(.*)$", cleaned, re.IGNORECASE)
+            if match:
+                matched_field = field
+                matched_value = match.group(1).strip()
+                break
+
+        if matched_field:
+            current_field = matched_field
+            _append_plot_value(structure, matched_field, matched_value)
+            continue
+
+        if current_field:
+            _append_plot_value(structure, current_field, cleaned)
+
+    cpns = structure.get("cpns") or []
+    mandatory_nodes = structure.get("mandatory_nodes") or []
+    prohibitions = structure.get("prohibitions") or []
+    if not any([structure.get("cbn"), cpns, structure.get("cen"), mandatory_nodes, prohibitions]):
+        return {}
+
+    return {
+        "cbn": str(structure.get("cbn") or "").strip(),
+        "cpns": cpns,
+        "cen": str(structure.get("cen") or "").strip(),
+        "mandatory_nodes": mandatory_nodes,
+        "prohibitions": prohibitions,
+        "source": "chapter_outline",
+    }
+
+
+def load_chapter_plot_structure(project_root: Path, chapter_num: int) -> Dict[str, Any]:
+    outline = load_chapter_outline(project_root, chapter_num, max_chars=None)
+    return parse_chapter_plot_structure(outline)

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

@@ -51,6 +51,10 @@ __all__ = [
     "StyleSampler",
     "StyleSample",
     "SceneType",
+    # Memory
+    "ScratchpadManager",
+    "MemoryWriter",
+    "MemoryOrchestrator",
 ]
 
 
@@ -87,6 +91,10 @@ _LAZY_EXPORTS: dict[str, tuple[str, str]] = {
     "StyleSampler": (".style_sampler", "StyleSampler"),
     "StyleSample": (".style_sampler", "StyleSample"),
     "SceneType": (".style_sampler", "SceneType"),
+    # Memory
+    "ScratchpadManager": (".memory.store", "ScratchpadManager"),
+    "MemoryWriter": (".memory.writer", "MemoryWriter"),
+    "MemoryOrchestrator": (".memory.orchestrator", "MemoryOrchestrator"),
 }
 
 
@@ -103,4 +111,3 @@ def __getattr__(name: str) -> Any:  # pragma: no cover
 
 def __dir__() -> list[str]:  # pragma: no cover
     return sorted(set(list(globals().keys()) + list(_LAZY_EXPORTS.keys())))
-

+ 10 - 0
webnovel-writer/scripts/data_modules/config.py

@@ -102,6 +102,10 @@ class DataModulesConfig:
     def state_file(self) -> Path:
         return self.webnovel_dir / "state.json"
 
+    @property
+    def scratchpad_file(self) -> Path:
+        return self.webnovel_dir / "memory_scratchpad.json"
+
     @property
     def index_db(self) -> Path:
         return self.webnovel_dir / "index.db"
@@ -256,6 +260,12 @@ class DataModulesConfig:
         ",",
         "、",
     )
+    context_use_memory_orchestrator: bool = False
+    memory_orchestrator_max_items: int = 30
+    memory_orchestrator_recent_changes_limit: int = 10
+    memory_orchestrator_source_window: int = 20
+    memory_compactor_enabled: bool = True
+    memory_compactor_threshold: int = 500
 
     export_recent_changes_slice: int = 20
     export_disambiguation_slice: int = 20

+ 52 - 3
webnovel-writer/scripts/data_modules/context_manager.py

@@ -15,9 +15,9 @@ from runtime_compat import enable_windows_utf8_stdio
 from typing import Any, Dict, List, Optional
 
 try:
-    from chapter_outline_loader import load_chapter_outline
+    from chapter_outline_loader import load_chapter_outline, load_chapter_plot_structure
 except ImportError:  # pragma: no cover
-    from scripts.chapter_outline_loader import load_chapter_outline
+    from scripts.chapter_outline_loader import load_chapter_outline, load_chapter_plot_structure
 
 from .config import get_config
 from .index_manager import IndexManager, WritingChecklistScoreMeta
@@ -54,11 +54,13 @@ class ContextManager:
     EXTRA_SECTIONS = {
         "story_skeleton",
         "memory",
+        "long_term_memory",
         "preferences",
         "alerts",
         "reader_signal",
         "genre_profile",
         "writing_guidance",
+        "plot_structure",
     }
     SECTION_ORDER = [
         "core",
@@ -67,8 +69,10 @@ class ContextManager:
         "reader_signal",
         "genre_profile",
         "writing_guidance",
+        "plot_structure",
         "story_skeleton",
         "memory",
+        "long_term_memory",
         "preferences",
         "alerts",
     ]
@@ -94,7 +98,18 @@ class ContextManager:
         if not isinstance(cached_template, str):
             return template == self.DEFAULT_TEMPLATE
 
-        return cached_template == template
+        if cached_template != template:
+            return False
+
+        payload = cached.get("payload", cached)
+        if not isinstance(payload, dict):
+            return False
+        sections = payload.get("sections")
+        if not isinstance(sections, dict):
+            return False
+
+        required_sections = {"plot_structure", "long_term_memory"}
+        return required_sections.issubset(set(sections.keys()))
 
     def build_context(
         self,
@@ -188,6 +203,18 @@ class ContextManager:
 
     def _build_pack(self, chapter: int) -> Dict[str, Any]:
         state = self._load_state()
+        use_orchestrator = bool(getattr(self.config, "context_use_memory_orchestrator", False))
+
+        orchestrator_pack: Dict[str, Any] = {}
+        if use_orchestrator:
+            try:
+                from .memory.orchestrator import MemoryOrchestrator
+
+                orchestrator = MemoryOrchestrator(self.config)
+                orchestrator_pack = orchestrator.build_memory_pack(chapter)
+            except Exception as exc:
+                logger.warning("memory_orchestrator_failed: %s", exc)
+
         core = {
             "chapter_outline": self._load_outline(chapter),
             "protagonist_snapshot": state.get("protagonist_state", {}),
@@ -201,6 +228,21 @@ class ContextManager:
                 window=self.config.context_recent_meta_window,
             ),
         }
+        if use_orchestrator and orchestrator_pack:
+            working_items = list(orchestrator_pack.get("working_memory") or [])
+            outline_item = next((x for x in working_items if x.get("source") == "outline"), None)
+            state_item = next((x for x in working_items if x.get("source") == "state_export"), None)
+            summary_items = [
+                {"chapter": x.get("chapter"), "summary": x.get("content")}
+                for x in working_items
+                if x.get("source") == "summary"
+            ]
+            core["chapter_outline"] = str(outline_item.get("content", "")) if outline_item else core["chapter_outline"]
+            if isinstance(state_item, dict) and isinstance(state_item.get("content"), dict):
+                state_export = dict(state_item.get("content") or {})
+                core["protagonist_snapshot"] = state_export.get("protagonist_state", core["protagonist_snapshot"])
+            if summary_items:
+                core["recent_summaries"] = summary_items
 
         scene = {
             "location_context": state.get("protagonist_state", {}).get("location", {}),
@@ -220,11 +262,13 @@ class ContextManager:
 
         preferences = self._load_json_optional(self.config.webnovel_dir / "preferences.json")
         memory = self._load_json_optional(self.config.webnovel_dir / "project_memory.json")
+        long_term_memory: Dict[str, Any] = orchestrator_pack if orchestrator_pack else {}
         story_skeleton = self._load_story_skeleton(chapter)
         alert_slice = max(0, int(self.config.context_alerts_slice))
         reader_signal = self._load_reader_signal(chapter)
         genre_profile = self._load_genre_profile(state)
         writing_guidance = self._build_writing_guidance(chapter, reader_signal, genre_profile)
+        plot_structure = self._load_plot_structure(chapter)
 
         return {
             "meta": {"chapter": chapter},
@@ -234,9 +278,11 @@ class ContextManager:
             "reader_signal": reader_signal,
             "genre_profile": genre_profile,
             "writing_guidance": writing_guidance,
+            "plot_structure": plot_structure,
             "story_skeleton": story_skeleton,
             "preferences": preferences,
             "memory": memory,
+            "long_term_memory": long_term_memory,
             "alerts": {
                 "disambiguation_warnings": (
                     state.get("disambiguation_warnings", [])[-alert_slice:] if alert_slice else []
@@ -640,6 +686,9 @@ class ContextManager:
     def _load_outline(self, chapter: int) -> str:
         return load_chapter_outline(self.config.project_root, chapter, max_chars=1500)
 
+    def _load_plot_structure(self, chapter: int) -> Dict[str, Any]:
+        return load_chapter_plot_structure(self.config.project_root, chapter)
+
     def _load_recent_summaries(self, chapter: int, window: int = 3) -> List[Dict[str, Any]]:
         summaries = []
         for ch in range(max(1, chapter - window), chapter):

+ 194 - 2
webnovel-writer/scripts/data_modules/index_manager.py

@@ -39,7 +39,7 @@ from pathlib import Path
 
 from runtime_compat import enable_windows_utf8_stdio
 from typing import Dict, List, Optional, Any, Tuple
-from dataclasses import dataclass, field
+from dataclasses import dataclass, field, asdict
 from contextlib import contextmanager
 from datetime import datetime
 
@@ -205,6 +205,45 @@ class ReviewMetrics:
     notes: str = ""
 
 
+@dataclass
+class ReviewAggregateResult:
+    """Step 3 审查聚合结果"""
+
+    chapter: int
+    start_chapter: int
+    end_chapter: int
+    selected_checkers: List[str] = field(default_factory=list)
+    checkers: Dict[str, Dict[str, Any]] = field(default_factory=dict)
+    issues: List[Dict[str, Any]] = field(default_factory=list)
+    overall_score: float = 0.0
+    severity_counts: Dict[str, int] = field(default_factory=dict)
+    critical_issues: List[str] = field(default_factory=list)
+    dimension_scores: Dict[str, float] = field(default_factory=dict)
+    overall: Dict[str, Any] = field(default_factory=dict)
+    notes: str = ""
+    timeline_gate: Dict[str, Any] = field(default_factory=dict)
+
+    def to_review_metrics(self, report_file: str = "") -> ReviewMetrics:
+        notes = str(self.notes or "")
+        timeline_gate = self.timeline_gate or {}
+        if timeline_gate:
+            gate_note = (
+                f"timeline_gate:block={bool(timeline_gate.get('blocked', False))};"
+                f"count={int(timeline_gate.get('blocking_issue_count', 0) or 0)}"
+            )
+            notes = f"{notes} | {gate_note}" if notes else gate_note
+        return ReviewMetrics(
+            start_chapter=self.start_chapter,
+            end_chapter=self.end_chapter,
+            overall_score=self.overall_score,
+            dimension_scores=self.dimension_scores,
+            severity_counts=self.severity_counts,
+            critical_issues=self.critical_issues,
+            report_file=report_file,
+            notes=notes,
+        )
+
+
 @dataclass
 class WritingChecklistScoreMeta:
     """写作清单评分记录(Context Contract v2 Phase F)"""
@@ -460,7 +499,7 @@ class IndexManager(IndexChapterMixin, IndexEntityMixin, IndexDebtMixin, IndexRea
                     chapter INTEGER NOT NULL,
                     note TEXT,
                     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-                    FOREIGN KEY (debt_id) REFERENCES chase_debt(id)
+                    FOREIGN KEY (debt_id) REFERENCES chase_debt(id) ON DELETE CASCADE
                 )
             """)
 
@@ -634,6 +673,139 @@ class IndexManager(IndexChapterMixin, IndexEntityMixin, IndexDebtMixin, IndexRea
 # ==================== CLI 接口 ====================
 
 
+
+
+def _normalize_checker_issue(issue: object) -> dict:
+    if not isinstance(issue, dict):
+        return {}
+    return {
+        "id": str(issue.get("id") or ""),
+        "type": str(issue.get("type") or ""),
+        "severity": str(issue.get("severity") or "medium"),
+        "location": str(issue.get("location") or ""),
+        "description": str(issue.get("description") or ""),
+        "suggestion": str(issue.get("suggestion") or ""),
+        "can_override": bool(issue.get("can_override", False)),
+    }
+
+
+def _build_timeline_gate(issues: List[Dict[str, Any]]) -> Dict[str, Any]:
+    blocking = []
+    for issue in issues:
+        if not isinstance(issue, dict):
+            continue
+        issue_type = str(issue.get("type") or "").strip()
+        severity = str(issue.get("severity") or "").strip().lower()
+        if issue_type == "TIMELINE_ISSUE" and severity in {"critical", "high"}:
+            blocking.append(issue)
+    return {
+        "blocked": bool(blocking),
+        "blocking_issue_count": len(blocking),
+        "blocking_issues": blocking,
+    }
+
+
+def _aggregate_checker_results(chapter: int, raw_data: object) -> dict:
+    if isinstance(raw_data, dict) and isinstance(raw_data.get("checkers"), dict):
+        checker_map = dict(raw_data.get("checkers") or {})
+    elif isinstance(raw_data, dict):
+        checker_map = dict(raw_data)
+    elif isinstance(raw_data, list):
+        checker_map = {}
+        for item in raw_data:
+            if isinstance(item, dict):
+                agent = str(item.get("agent") or "").strip()
+                if agent:
+                    checker_map[agent] = item
+    else:
+        checker_map = {}
+
+    selected_checkers: list[str] = []
+    issues: list[dict] = []
+    severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
+    critical_issues: list[str] = []
+    dimension_scores: dict[str, float] = {}
+    score_values: list[float] = []
+    aggregated_checkers: dict[str, dict] = {}
+
+    dimension_alias = {
+        "consistency-checker": "consistency",
+        "continuity-checker": "continuity",
+        "ooc-checker": "ooc",
+        "reader-pull-checker": "reader_pull",
+        "high-point-checker": "high_point",
+        "pacing-checker": "pacing",
+    }
+
+    for agent, payload in checker_map.items():
+        if not isinstance(payload, dict):
+            continue
+        selected_checkers.append(agent)
+        score = payload.get("overall_score")
+        try:
+            numeric_score = float(score)
+            score_values.append(numeric_score)
+            dimension_scores[dimension_alias.get(agent, agent)] = round(numeric_score, 2)
+        except (TypeError, ValueError):
+            numeric_score = None
+
+        raw_issues = payload.get("issues") or []
+        normalized_issues = []
+        critical_count = 0
+        high_count = 0
+        for raw_issue in raw_issues:
+            issue = _normalize_checker_issue(raw_issue)
+            if not issue:
+                continue
+            normalized_issues.append(issue)
+            issues.append({**issue, "agent": agent, "chapter": chapter})
+            severity = issue.get("severity") or "medium"
+            if severity not in severity_counts:
+                severity = "medium"
+            severity_counts[severity] += 1
+            if severity == "critical":
+                critical_count += 1
+                if issue.get("description"):
+                    critical_issues.append(issue["description"])
+            elif severity == "high":
+                high_count += 1
+
+        aggregated_checkers[agent] = {
+            "score": numeric_score,
+            "pass": bool(payload.get("pass", False)),
+            "critical": critical_count,
+            "high": high_count,
+        }
+
+    overall_score = round(sum(score_values) / len(score_values), 2) if score_values else 0.0
+    timeline_gate = _build_timeline_gate(issues)
+    overall = {
+        "score": overall_score,
+        "pass": severity_counts["critical"] == 0 and not timeline_gate.get("blocked", False),
+        "critical_total": severity_counts["critical"],
+        "high_total": severity_counts["high"],
+        "can_proceed": severity_counts["critical"] == 0 and not timeline_gate.get("blocked", False),
+    }
+
+    notes = "selected_checkers=" + ",".join(selected_checkers)
+    result = ReviewAggregateResult(
+        chapter=chapter,
+        start_chapter=chapter,
+        end_chapter=chapter,
+        selected_checkers=selected_checkers,
+        checkers=aggregated_checkers,
+        issues=issues,
+        overall_score=overall_score,
+        severity_counts=severity_counts,
+        critical_issues=critical_issues,
+        dimension_scores=dimension_scores,
+        overall=overall,
+        notes=notes,
+        timeline_gate=timeline_gate,
+    )
+    return asdict(result)
+
+
 def main():
     import argparse
     import sys
@@ -788,6 +960,15 @@ def main():
     review_trend_parser = subparsers.add_parser("get-review-trend-stats")
     review_trend_parser.add_argument("--last-n", type=int, default=5)
 
+    review_aggregate_parser = subparsers.add_parser("aggregate-review-results")
+    review_aggregate_parser.add_argument("--chapter", type=int, required=True)
+    review_aggregate_parser.add_argument("--data", required=True, help="JSON 格式的 checker 原始结果列表或映射")
+
+    review_materialize_parser = subparsers.add_parser("materialize-review-metrics")
+    review_materialize_parser.add_argument("--chapter", type=int, required=True)
+    review_materialize_parser.add_argument("--data", required=True, help="JSON 格式的 checker 原始结果列表或映射")
+    review_materialize_parser.add_argument("--report-file", default="", help="审查报告路径")
+
     checklist_score_save_parser = subparsers.add_parser("save-writing-checklist-score")
     checklist_score_save_parser.add_argument("--data", required=True, help="JSON 格式的写作清单评分数据")
 
@@ -1134,6 +1315,17 @@ def main():
         rows = manager.list_invalid_facts(args.status)
         emit_success(rows, message="invalid_list")
 
+    elif args.command == "aggregate-review-results":
+        data = load_json_arg(args.data)
+        aggregated = _aggregate_checker_results(args.chapter, data)
+        emit_success(aggregated, message="review_results_aggregated", chapter=args.chapter)
+
+    elif args.command == "materialize-review-metrics":
+        data = load_json_arg(args.data)
+        aggregated = ReviewAggregateResult(**_aggregate_checker_results(args.chapter, data))
+        metrics = aggregated.to_review_metrics(report_file=args.report_file)
+        emit_success(asdict(metrics), message="review_metrics_materialized", chapter=args.chapter)
+
     elif args.command == "save-review-metrics":
         data = load_json_arg(args.data)
         metrics = ReviewMetrics(

+ 28 - 0
webnovel-writer/scripts/data_modules/memory/__init__.py

@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+长期记忆子系统。
+"""
+
+from .schema import (
+    BUCKET_TO_CATEGORY,
+    CATEGORY_KEY_RULES,
+    CATEGORY_TO_BUCKET,
+    MemoryItem,
+    ScratchpadData,
+)
+from .store import ScratchpadManager
+from .writer import MemoryWriter
+from .orchestrator import MemoryOrchestrator
+
+__all__ = [
+    "MemoryItem",
+    "ScratchpadData",
+    "CATEGORY_TO_BUCKET",
+    "BUCKET_TO_CATEGORY",
+    "CATEGORY_KEY_RULES",
+    "ScratchpadManager",
+    "MemoryWriter",
+    "MemoryOrchestrator",
+]
+

+ 182 - 0
webnovel-writer/scripts/data_modules/memory/bootstrap.py

@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+从 index.db 回填初始长期记忆。
+"""
+from __future__ import annotations
+
+import re
+from typing import Any, Dict
+
+from ..config import DataModulesConfig, get_config
+from ..index_manager import IndexManager
+from .schema import MemoryItem
+from .store import ScratchpadManager
+
+
+FORESHADOWING_SECTION_RE = re.compile(r"##\s*伏笔\s*\r?\n(.*?)(?=\r?\n##|\Z)", re.DOTALL)
+FORESHADOWING_BULLET_RE = re.compile(r"^\s*[-*]\s+(.+?)\s*$", re.MULTILINE)
+
+
+def _extract_chapter_from_name(name: str) -> int:
+    m = re.search(r"ch(\d{1,6})", name, re.IGNORECASE)
+    if m:
+        return int(m.group(1))
+    m = re.search(r"第\s*(\d+)\s*章", name)
+    if m:
+        return int(m.group(1))
+    return 0
+
+
+def _extract_open_loops(summary_text: str) -> list[str]:
+    if not summary_text:
+        return []
+    section_match = FORESHADOWING_SECTION_RE.search(summary_text)
+    if not section_match:
+        return []
+    block = section_match.group(1).strip()
+    if not block:
+        return []
+    loops = []
+    for m in FORESHADOWING_BULLET_RE.finditer(block):
+        text = str(m.group(1) or "").strip()
+        if text:
+            loops.append(text)
+    return loops
+
+
+def bootstrap_from_index(config: DataModulesConfig | None = None) -> Dict[str, Any]:
+    cfg = config or get_config()
+    idx = IndexManager(cfg)
+    store = ScratchpadManager(cfg)
+
+    created = 0
+    by_category: Dict[str, int] = {}
+
+    for entity in idx.get_entities_by_type("角色", include_archived=True):
+        entity_id = str(entity.get("id", "") or "").strip()
+        current = entity.get("current_json") or {}
+        if not entity_id or not isinstance(current, dict):
+            continue
+        for field, value in current.items():
+            item = MemoryItem(
+                id=f"bootstrap-character_state-{entity_id}-{field}",
+                layer="semantic",
+                category="character_state",
+                subject=entity_id,
+                field=str(field),
+                value=str(value),
+                payload={},
+                source_chapter=int(entity.get("last_appearance") or 0),
+                evidence=["bootstrap:index_entities"],
+            )
+            store.upsert_item(item)
+            created += 1
+            by_category["character_state"] = by_category.get("character_state", 0) + 1
+
+    # 回填状态变化:最新值 active,历史值 outdated。
+    changes = idx.get_recent_state_changes(limit=5000)
+    changes_sorted = sorted(
+        changes,
+        key=lambda x: (int(x.get("chapter") or 0), int(x.get("id") or 0)),
+    )
+    latest_by_key: Dict[tuple[str, str], Dict[str, Any]] = {}
+    history_rows: list[Dict[str, Any]] = []
+    for row in changes_sorted:
+        entity_id = str(row.get("entity_id", "") or "").strip()
+        field = str(row.get("field", "") or "").strip()
+        if not entity_id or not field:
+            continue
+        key = (entity_id, field)
+        if key in latest_by_key:
+            history_rows.append(latest_by_key[key])
+        latest_by_key[key] = row
+
+    for row in history_rows:
+        entity_id = str(row.get("entity_id", "") or "").strip()
+        field = str(row.get("field", "") or "").strip()
+        ch = int(row.get("chapter") or 0)
+        val = str(row.get("new_value", "") or "")
+        if not entity_id or not field or not val:
+            continue
+        item = MemoryItem(
+            id=f"bootstrap-state-{entity_id}-{field}-{ch}",
+            layer="semantic",
+            category="character_state",
+            subject=entity_id,
+            field=field,
+            value=val,
+            payload={"old_value": str(row.get("old_value", "") or ""), "reason": str(row.get("reason", "") or "")},
+            status="outdated",
+            source_chapter=ch,
+            evidence=["bootstrap:index_state_changes"],
+        )
+        store.upsert_item(item)
+        created += 1
+        by_category["character_state"] = by_category.get("character_state", 0) + 1
+
+    for (entity_id, field), row in latest_by_key.items():
+        ch = int(row.get("chapter") or 0)
+        val = str(row.get("new_value", "") or "")
+        if not val:
+            continue
+        item = MemoryItem(
+            id=f"bootstrap-state-latest-{entity_id}-{field}",
+            layer="semantic",
+            category="character_state",
+            subject=entity_id,
+            field=field,
+            value=val,
+            payload={"old_value": str(row.get("old_value", "") or ""), "reason": str(row.get("reason", "") or "")},
+            status="active",
+            source_chapter=ch,
+            evidence=["bootstrap:index_state_changes_latest"],
+        )
+        store.upsert_item(item)
+        created += 1
+        by_category["character_state"] = by_category.get("character_state", 0) + 1
+
+    for rel in idx.get_recent_relationships(limit=500):
+        from_entity = str(rel.get("from_entity", "") or "").strip()
+        to_entity = str(rel.get("to_entity", "") or "").strip()
+        if not from_entity or not to_entity:
+            continue
+        item = MemoryItem(
+            id=f"bootstrap-relationship-{from_entity}-{to_entity}",
+            layer="semantic",
+            category="relationship",
+            subject=from_entity,
+            field=to_entity,
+            value=str(rel.get("type", "") or ""),
+            payload={"description": rel.get("description", "")},
+            source_chapter=int(rel.get("chapter") or 0),
+            evidence=["bootstrap:index_relationships"],
+        )
+        store.upsert_item(item)
+        created += 1
+        by_category["relationship"] = by_category.get("relationship", 0) + 1
+
+    # 从 summaries 中抽取“伏笔”区块回填 open_loop。
+    summaries_dir = cfg.webnovel_dir / "summaries"
+    if summaries_dir.exists():
+        for path in sorted(summaries_dir.glob("*.md")):
+            text = path.read_text(encoding="utf-8")
+            chapter = _extract_chapter_from_name(path.stem)
+            for idx_loop, loop in enumerate(_extract_open_loops(text), start=1):
+                item = MemoryItem(
+                    id=f"bootstrap-open-loop-{chapter}-{idx_loop}",
+                    layer="semantic",
+                    category="open_loop",
+                    subject=loop[:64],
+                    field="status",
+                    value=loop,
+                    payload={"planted_chapter": chapter, "urgency": 50, "status": "active"},
+                    status="active",
+                    source_chapter=chapter,
+                    evidence=["bootstrap:summaries_foreshadowing"],
+                )
+                store.upsert_item(item)
+                created += 1
+                by_category["open_loop"] = by_category.get("open_loop", 0) + 1
+
+    return {"items_created": created, "categories": by_category}

+ 52 - 0
webnovel-writer/scripts/data_modules/memory/budget.py

@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+长期记忆预算分配。
+"""
+from __future__ import annotations
+
+from typing import Dict
+
+
+DEFAULT_BUDGET = {
+    "write": {"max_items": 30, "working_ratio": 0.45, "episodic_ratio": 0.30, "semantic_ratio": 0.25},
+    "review": {"max_items": 40, "working_ratio": 0.35, "episodic_ratio": 0.35, "semantic_ratio": 0.30},
+    "query": {"max_items": 25, "working_ratio": 0.30, "episodic_ratio": 0.45, "semantic_ratio": 0.25},
+}
+
+
+def get_budget(task_type: str = "write") -> Dict[str, float]:
+    key = str(task_type or "write").lower()
+    return dict(DEFAULT_BUDGET.get(key, DEFAULT_BUDGET["write"]))
+
+
+def allocate_limits(max_items: int, task_type: str = "write") -> Dict[str, int]:
+    """按任务类型分配 working/episodic/semantic 的条目预算。"""
+    max_items = max(1, int(max_items or 1))
+    budget = get_budget(task_type)
+    wr = float(budget.get("working_ratio", 0.45))
+    er = float(budget.get("episodic_ratio", 0.30))
+    sr = float(budget.get("semantic_ratio", 0.25))
+
+    total_ratio = wr + er + sr
+    if total_ratio <= 0:
+        wr, er, sr = 0.45, 0.30, 0.25
+        total_ratio = 1.0
+    wr, er, sr = wr / total_ratio, er / total_ratio, sr / total_ratio
+
+    w = int(max_items * wr)
+    e = int(max_items * er)
+    s = int(max_items * sr)
+    used = w + e + s
+
+    # 把余数按语义层优先分配,保证总和等于 max_items。
+    while used < max_items:
+        if s <= w:
+            s += 1
+        elif w <= e:
+            w += 1
+        else:
+            e += 1
+        used += 1
+
+    return {"working": w, "episodic": e, "semantic": s}

+ 111 - 0
webnovel-writer/scripts/data_modules/memory/compactor.py

@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+scratchpad 压缩器。
+"""
+from __future__ import annotations
+
+from typing import Dict, List, Tuple
+
+from .schema import CATEGORY_KEY_RULES, CATEGORY_TO_BUCKET, MemoryItem, ScratchpadData, now_iso
+
+
+def _key_for(item: MemoryItem) -> Tuple:
+    fields = CATEGORY_KEY_RULES.get(item.category)
+    if not fields:
+        return (item.id,)
+    return tuple(getattr(item, f, None) for f in fields)
+
+
+def _is_resolved_open_loop(item: MemoryItem) -> bool:
+    if item.category != "open_loop":
+        return False
+    state = str((item.payload or {}).get("status", "") or "").strip().lower()
+    return state in {"resolved", "closed", "done", "paid_off", "payoff"}
+
+
+def compact_scratchpad(data: ScratchpadData, max_items: int = 500) -> ScratchpadData:
+    if data.count_items() <= max_items:
+        return data
+
+    # 1) 同 key 的 outdated 只保留最新,避免历史膨胀。
+    for bucket in CATEGORY_TO_BUCKET.values():
+        rows: List[MemoryItem] = list(getattr(data, bucket))
+        latest_outdated: Dict[Tuple, MemoryItem] = {}
+        keep: List[MemoryItem] = []
+        for row in rows:
+            if row.status != "outdated":
+                keep.append(row)
+                continue
+            key = _key_for(row)
+            prev = latest_outdated.get(key)
+            if prev is None or (row.updated_at or "") >= (prev.updated_at or ""):
+                latest_outdated[key] = row
+        keep.extend(latest_outdated.values())
+        setattr(data, bucket, keep)
+
+    # 2) 清理已回收伏笔。
+    data.open_loops = [row for row in data.open_loops if not _is_resolved_open_loop(row)]
+
+    # 3) 压缩过旧 timeline(与当前最新章节相距 50 章以上)。
+    timeline = sorted(data.timeline, key=lambda x: x.source_chapter)
+    if timeline:
+        latest_chapter = max(x.source_chapter for x in timeline)
+        old = [x for x in timeline if (latest_chapter - x.source_chapter) > 50]
+        fresh = [x for x in timeline if (latest_chapter - x.source_chapter) <= 50]
+        if len(old) > 1:
+            samples = []
+            for row in old[:8]:
+                label = row.value or row.subject or row.field or row.id
+                if label:
+                    samples.append(str(label))
+            summary_text = ";".join(samples) if samples else "早期关键事件"
+            summary_item = MemoryItem(
+                id=f"timeline-summary-upto-{old[-1].source_chapter}",
+                layer="semantic",
+                category="story_fact",
+                subject="timeline_summary",
+                field=f"<=ch{old[-1].source_chapter}",
+                value=f"早期事件摘要:{summary_text}",
+                payload={
+                    "from_chapter": old[0].source_chapter,
+                    "to_chapter": old[-1].source_chapter,
+                    "items_merged": len(old),
+                },
+                status="active",
+                source_chapter=old[-1].source_chapter,
+                evidence=["compactor:timeline"],
+                updated_at=now_iso(),
+            )
+            replaced = False
+            for i, row in enumerate(list(data.story_facts)):
+                if row.subject == summary_item.subject and row.field == summary_item.field:
+                    data.story_facts[i] = summary_item
+                    replaced = True
+                    break
+            if not replaced:
+                data.story_facts.append(summary_item)
+        data.timeline = fresh
+
+    # 4) 若仍超限,按状态和新鲜度做全局截断,保证总量不超过 max_items。
+    if data.count_items() > max_items:
+        ranked: List[Tuple[str, MemoryItem]] = []
+        for bucket in CATEGORY_TO_BUCKET.values():
+            for row in list(getattr(data, bucket)):
+                ranked.append((bucket, row))
+
+        ranked.sort(
+            key=lambda item: (
+                0 if item[1].status == "active" else 1,
+                -int(item[1].source_chapter or 0),
+                item[1].updated_at or "",
+            )
+        )
+        keep = ranked[:max_items]
+        kept_ids = {item.id for _, item in keep}
+        for bucket in CATEGORY_TO_BUCKET.values():
+            rows = [row for row in list(getattr(data, bucket)) if row.id in kept_ids]
+            setattr(data, bucket, rows)
+
+    data.meta = {**dict(data.meta or {}), "last_updated": now_iso(), "total_items": data.count_items()}
+    return data

+ 217 - 0
webnovel-writer/scripts/data_modules/memory/orchestrator.py

@@ -0,0 +1,217 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+长期记忆编排器。
+"""
+from __future__ import annotations
+
+import json
+from typing import Any, Dict, List
+
+from ..config import DataModulesConfig, get_config
+from ..index_manager import IndexManager
+from .schema import MemoryItem
+from .store import ScratchpadManager
+from .budget import allocate_limits
+
+try:
+    from chapter_outline_loader import load_chapter_outline
+except ImportError:  # pragma: no cover
+    from scripts.chapter_outline_loader import load_chapter_outline
+
+
+class MemoryOrchestrator:
+    PRIORITY = {
+        "world_rule": 0,
+        "character_state": 1,
+        "relationship": 2,
+        "story_fact": 3,
+        "open_loop": 4,
+        "reader_promise": 5,
+        "timeline": 6,
+    }
+
+    def __init__(self, config: DataModulesConfig | None = None):
+        self.config = config or get_config()
+        self.index_manager = IndexManager(self.config)
+        self.store = ScratchpadManager(self.config)
+
+    def build_memory_pack(self, chapter: int, task_type: str = "write") -> Dict[str, Any]:
+        outline = load_chapter_outline(self.config.project_root, chapter, max_chars=1500)
+
+        working = self._build_working_memory(chapter=chapter, outline=outline)
+        episodic = self._build_episodic_memory(chapter=chapter)
+        active_items = self.store.query(status="active")
+        conflicts = self.store.conflicts()
+        filtered = self._filter_relevant(active_items, chapter=chapter, outline=outline)
+
+        max_items = max(1, int(getattr(self.config, "memory_orchestrator_max_items", 30)))
+        limits = allocate_limits(max_items=max_items, task_type=task_type)
+        semantic_items = self._apply_budget(filtered, max_items=limits["semantic"])
+        working_items = working[: limits["working"]]
+        episodic_items = episodic[: limits["episodic"]]
+        semantic_payload = [item.to_dict() for item in semantic_items]
+
+        recent_changes = self.index_manager.get_recent_state_changes(
+            limit=max(1, int(getattr(self.config, "memory_orchestrator_recent_changes_limit", 10)))
+        )
+
+        active_constraints = [
+            item.to_dict()
+            for item in semantic_items
+            if item.category in {"world_rule", "open_loop"}
+        ]
+        warnings = []
+        if conflicts:
+            warnings.append(
+                {
+                    "type": "memory_conflict",
+                    "count": len(conflicts),
+                    "sample": conflicts[:5],
+                }
+            )
+
+        return {
+            "working_memory": working_items,
+            "episodic_memory": episodic_items,
+            "semantic_memory": semantic_payload,
+            # long_term_facts 保持对外 contract:仅包含可直接注入的长期语义事实。
+            "long_term_facts": semantic_payload,
+            "active_constraints": active_constraints,
+            "recent_changes": list(recent_changes),
+            "warnings": warnings,
+            "stats": {
+                "total": len(active_items),
+                "working_total": len(working),
+                "episodic_total": len(episodic),
+                "semantic_total": len(filtered),
+                "injected": len(semantic_payload),
+                "layered_total_injected": len(working_items) + len(episodic_items) + len(semantic_payload),
+                "filtered": max(0, len(active_items) - len(filtered)),
+                "conflicts": len(conflicts),
+            },
+        }
+
+    def _filter_relevant(self, items: List[MemoryItem], chapter: int, outline: str) -> List[MemoryItem]:
+        if not items:
+            return []
+        if not outline:
+            return sorted(items, key=lambda x: (x.source_chapter, x.updated_at), reverse=True)
+
+        keep: List[MemoryItem] = []
+        source_window = max(1, int(getattr(self.config, "memory_orchestrator_source_window", 20)))
+        for item in items:
+            if item.subject and item.subject in outline:
+                keep.append(item)
+                continue
+            if item.field and item.field in outline:
+                keep.append(item)
+                continue
+            if item.value and item.value[:20] in outline:
+                keep.append(item)
+                continue
+            if item.source_chapter > 0 and chapter - item.source_chapter <= source_window:
+                keep.append(item)
+
+        return sorted(keep, key=lambda x: (self.PRIORITY.get(x.category, 99), -x.source_chapter))
+
+    def _apply_budget(self, items: List[MemoryItem], max_items: int) -> List[MemoryItem]:
+        if max_items <= 0:
+            return []
+        if len(items) <= max_items:
+            return list(items)
+        return list(items[:max_items])
+
+    def _load_state(self) -> Dict[str, Any]:
+        path = self.config.state_file
+        if not path.exists():
+            return {}
+        try:
+            return json.loads(path.read_text(encoding="utf-8"))
+        except Exception:
+            return {}
+
+    def _load_recent_summaries(self, chapter: int, window: int) -> List[Dict[str, Any]]:
+        result: List[Dict[str, Any]] = []
+        summary_dir = self.config.webnovel_dir / "summaries"
+        if not summary_dir.exists():
+            return result
+        for ch in range(max(1, chapter - window), chapter):
+            path = summary_dir / f"ch{ch:04d}.md"
+            if not path.exists():
+                continue
+            text = path.read_text(encoding="utf-8")
+            if text:
+                result.append({"layer": "working", "source": "summary", "chapter": ch, "content": text[:800]})
+        return result
+
+    def _build_working_memory(self, chapter: int, outline: str) -> List[Dict[str, Any]]:
+        state = self._load_state()
+        result: List[Dict[str, Any]] = []
+        if outline:
+            result.append({"layer": "working", "source": "outline", "chapter": chapter, "content": outline})
+
+        summary_window = max(1, int(getattr(self.config, "context_recent_summaries_window", 3)))
+        result.extend(self._load_recent_summaries(chapter=chapter, window=summary_window))
+
+        state_export = {
+            "protagonist_state": state.get("protagonist_state", {}),
+            "plot_threads": state.get("plot_threads", {}),
+            "disambiguation_pending": state.get("disambiguation_pending", []),
+        }
+        result.append(
+            {
+                "layer": "working",
+                "source": "state_export",
+                "chapter": chapter,
+                "content": state_export,
+            }
+        )
+        return result
+
+    def _build_episodic_memory(self, chapter: int) -> List[Dict[str, Any]]:
+        _ = chapter
+        changes_limit = max(1, int(getattr(self.config, "memory_orchestrator_recent_changes_limit", 10)))
+        rel_limit = max(1, min(20, changes_limit))
+
+        recent_changes = self.index_manager.get_recent_state_changes(limit=changes_limit)
+        recent_relationships = self.index_manager.get_recent_relationships(limit=rel_limit)
+        recent_appearances = self.index_manager.get_recent_appearances(limit=rel_limit)
+
+        result: List[Dict[str, Any]] = []
+        for row in recent_changes:
+            result.append(
+                {
+                    "layer": "episodic",
+                    "source": "state_change",
+                    "chapter": int(row.get("chapter") or 0),
+                    "entity_id": row.get("entity_id", ""),
+                    "field": row.get("field", ""),
+                    "content": row,
+                }
+            )
+        for row in recent_relationships:
+            result.append(
+                {
+                    "layer": "episodic",
+                    "source": "relationship",
+                    "chapter": int(row.get("chapter") or 0),
+                    "entity_id": row.get("from_entity", ""),
+                    "field": row.get("to_entity", ""),
+                    "content": row,
+                }
+            )
+        for row in recent_appearances:
+            result.append(
+                {
+                    "layer": "episodic",
+                    "source": "appearance",
+                    "chapter": int(row.get("chapter") or 0),
+                    "entity_id": row.get("entity_id", ""),
+                    "field": "appearance",
+                    "content": row,
+                }
+            )
+
+        result.sort(key=lambda x: int(x.get("chapter") or 0), reverse=True)
+        return result

+ 152 - 0
webnovel-writer/scripts/data_modules/memory/schema.py

@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+长期记忆 schema 定义。
+"""
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass, field
+from datetime import datetime
+from typing import Any, Dict, List
+
+
+VALID_LAYERS = {"semantic", "episodic"}
+VALID_STATUSES = {"active", "outdated", "contradicted", "tentative"}
+
+CATEGORY_TO_BUCKET: Dict[str, str] = {
+    "character_state": "character_state",
+    "story_fact": "story_facts",
+    "world_rule": "world_rules",
+    "timeline": "timeline",
+    "open_loop": "open_loops",
+    "reader_promise": "reader_promises",
+    "relationship": "relationships",
+}
+BUCKET_TO_CATEGORY: Dict[str, str] = {v: k for k, v in CATEGORY_TO_BUCKET.items()}
+
+CATEGORY_KEY_RULES: Dict[str, tuple[str, ...]] = {
+    "character_state": ("subject", "field"),
+    "relationship": ("subject", "field"),
+    "world_rule": ("subject", "field"),
+    "story_fact": ("subject", "field"),
+    "timeline": ("subject", "source_chapter"),
+    "open_loop": ("subject",),
+    "reader_promise": ("subject",),
+}
+
+
+def now_iso() -> str:
+    return datetime.now().isoformat(timespec="seconds")
+
+
+@dataclass
+class MemoryItem:
+    id: str
+    layer: str
+    category: str
+    subject: str
+    field: str
+    value: str
+    payload: Dict[str, Any] = field(default_factory=dict)
+    status: str = "active"
+    source_chapter: int = 0
+    evidence: List[str] = field(default_factory=list)
+    updated_at: str = ""
+
+    def normalized(self) -> "MemoryItem":
+        layer = self.layer if self.layer in VALID_LAYERS else "semantic"
+        category = self.category if self.category in CATEGORY_TO_BUCKET else "story_fact"
+        status = self.status if self.status in VALID_STATUSES else "active"
+        updated_at = self.updated_at or now_iso()
+        return MemoryItem(
+            id=str(self.id or ""),
+            layer=layer,
+            category=category,
+            subject=str(self.subject or ""),
+            field=str(self.field or ""),
+            value=str(self.value or ""),
+            payload=dict(self.payload or {}),
+            status=status,
+            source_chapter=int(self.source_chapter or 0),
+            evidence=[str(x) for x in (self.evidence or []) if str(x)],
+            updated_at=updated_at,
+        )
+
+    def to_dict(self) -> Dict[str, Any]:
+        return asdict(self.normalized())
+
+    @classmethod
+    def from_dict(cls, payload: Dict[str, Any]) -> "MemoryItem":
+        return cls(
+            id=str(payload.get("id", "")),
+            layer=str(payload.get("layer", "semantic")),
+            category=str(payload.get("category", "story_fact")),
+            subject=str(payload.get("subject", "")),
+            field=str(payload.get("field", "")),
+            value=str(payload.get("value", "")),
+            payload=dict(payload.get("payload") or {}),
+            status=str(payload.get("status", "active")),
+            source_chapter=int(payload.get("source_chapter", 0) or 0),
+            evidence=[str(x) for x in (payload.get("evidence") or []) if str(x)],
+            updated_at=str(payload.get("updated_at", "")),
+        ).normalized()
+
+
+@dataclass
+class ScratchpadData:
+    character_state: List[MemoryItem] = field(default_factory=list)
+    story_facts: List[MemoryItem] = field(default_factory=list)
+    world_rules: List[MemoryItem] = field(default_factory=list)
+    timeline: List[MemoryItem] = field(default_factory=list)
+    open_loops: List[MemoryItem] = field(default_factory=list)
+    reader_promises: List[MemoryItem] = field(default_factory=list)
+    relationships: List[MemoryItem] = field(default_factory=list)
+    meta: Dict[str, Any] = field(
+        default_factory=lambda: {"version": 1, "last_updated": "", "total_items": 0}
+    )
+
+    @classmethod
+    def empty(cls) -> "ScratchpadData":
+        return cls()
+
+    @classmethod
+    def from_dict(cls, payload: Dict[str, Any]) -> "ScratchpadData":
+        def _items(bucket: str) -> List[MemoryItem]:
+            rows = payload.get(bucket, [])
+            if not isinstance(rows, list):
+                return []
+            return [MemoryItem.from_dict(row) for row in rows if isinstance(row, dict)]
+
+        data = cls(
+            character_state=_items("character_state"),
+            story_facts=_items("story_facts"),
+            world_rules=_items("world_rules"),
+            timeline=_items("timeline"),
+            open_loops=_items("open_loops"),
+            reader_promises=_items("reader_promises"),
+            relationships=_items("relationships"),
+            meta=dict(payload.get("meta") or {}),
+        )
+        data.meta.setdefault("version", 1)
+        data.meta.setdefault("last_updated", "")
+        data.meta.setdefault("total_items", 0)
+        data.meta["total_items"] = data.count_items()
+        return data
+
+    def count_items(self) -> int:
+        return sum(
+            len(getattr(self, bucket))
+            for bucket in BUCKET_TO_CATEGORY
+        )
+
+    def to_dict(self) -> Dict[str, Any]:
+        result: Dict[str, Any] = {}
+        for bucket in BUCKET_TO_CATEGORY:
+            result[bucket] = [item.to_dict() for item in getattr(self, bucket)]
+        meta = dict(self.meta or {})
+        meta["version"] = int(meta.get("version", 1) or 1)
+        meta["last_updated"] = meta.get("last_updated") or now_iso()
+        meta["total_items"] = self.count_items()
+        result["meta"] = meta
+        return result
+

+ 253 - 0
webnovel-writer/scripts/data_modules/memory/store.py

@@ -0,0 +1,253 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+scratchpad 持久化与查询。
+"""
+from __future__ import annotations
+
+from dataclasses import asdict
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from ..config import DataModulesConfig, get_config
+from ..cli_output import print_error, print_success
+from ..cli_args import normalize_global_project_root, load_json_arg
+
+from .schema import (
+    BUCKET_TO_CATEGORY,
+    CATEGORY_KEY_RULES,
+    CATEGORY_TO_BUCKET,
+    MemoryItem,
+    ScratchpadData,
+    now_iso,
+)
+
+try:
+    from security_utils import atomic_write_json, read_json_safe
+except ImportError:  # pragma: no cover
+    from scripts.security_utils import atomic_write_json, read_json_safe
+
+from filelock import FileLock
+
+
+class ScratchpadManager:
+    def __init__(self, config: DataModulesConfig | None = None):
+        self.config = config or get_config()
+        self.path = Path(self.config.scratchpad_file)
+        self._lock = FileLock(str(self.path) + ".lock", timeout=30)
+
+    def load(self) -> ScratchpadData:
+        if not self.path.exists():
+            return ScratchpadData.empty()
+        payload = read_json_safe(self.path, default={})
+        if not isinstance(payload, dict):
+            return ScratchpadData.empty()
+        return ScratchpadData.from_dict(payload)
+
+    def save(self, data: ScratchpadData, _use_lock: bool = True) -> None:
+        self.config.ensure_dirs()
+        if bool(getattr(self.config, "memory_compactor_enabled", True)):
+            threshold = max(1, int(getattr(self.config, "memory_compactor_threshold", 500)))
+            if data.count_items() > threshold:
+                from .compactor import compact_scratchpad
+
+                data = compact_scratchpad(data, max_items=threshold)
+        payload = data.to_dict()
+        payload.setdefault("meta", {})
+        payload["meta"]["last_updated"] = now_iso()
+        payload["meta"]["total_items"] = data.count_items()
+        atomic_write_json(self.path, payload, use_lock=_use_lock, backup=True)
+
+    def _key_for(self, item: MemoryItem) -> tuple[Any, ...]:
+        rule = CATEGORY_KEY_RULES.get(item.category)
+        if not rule:
+            return (item.id,)
+        values: list[Any] = []
+        for key in rule:
+            values.append(getattr(item, key, None))
+        return tuple(values)
+
+    def upsert_item(self, item: MemoryItem) -> Dict[str, int]:
+        normalized = item.normalized()
+        with self._lock:
+            data = self.load()
+            bucket = CATEGORY_TO_BUCKET[normalized.category]
+            rows: List[MemoryItem] = list(getattr(data, bucket))
+            target_key = self._key_for(normalized)
+
+            outdated = 0
+            replaced_existing = False
+            new_rows: List[MemoryItem] = []
+            for row in rows:
+                row_key = self._key_for(row)
+                if row_key == target_key and row.id != normalized.id:
+                    # 同 key 旧值降级为 outdated,保留审计轨迹
+                    if row.status != "outdated":
+                        row = MemoryItem(**{**asdict(row), "status": "outdated", "updated_at": now_iso()})
+                        outdated += 1
+                    replaced_existing = True
+                elif row.id == normalized.id:
+                    replaced_existing = True
+                    continue
+                new_rows.append(row)
+
+            normalized.updated_at = normalized.updated_at or now_iso()
+            new_rows.append(normalized)
+            setattr(data, bucket, new_rows)
+            self.save(data, _use_lock=False)
+
+        return {
+            "added": 0 if replaced_existing else 1,
+            "updated": 1 if replaced_existing else 0,
+            "outdated": outdated,
+        }
+
+    def mark_status(self, item_id: str, status: str) -> bool:
+        if not item_id:
+            return False
+        with self._lock:
+            data = self.load()
+            updated = False
+            for bucket in BUCKET_TO_CATEGORY:
+                rows: List[MemoryItem] = getattr(data, bucket)
+                for i, row in enumerate(rows):
+                    if row.id == item_id:
+                        rows[i] = MemoryItem(**{**asdict(row), "status": status, "updated_at": now_iso()})
+                        updated = True
+            if updated:
+                self.save(data, _use_lock=False)
+        return updated
+
+    def query(
+        self,
+        category: Optional[str] = None,
+        subject: Optional[str] = None,
+        status: Optional[str] = "active",
+    ) -> List[MemoryItem]:
+        data = self.load()
+        categories = [category] if category else list(CATEGORY_TO_BUCKET.keys())
+        result: List[MemoryItem] = []
+        for cat in categories:
+            bucket = CATEGORY_TO_BUCKET.get(cat)
+            if not bucket:
+                continue
+            rows: List[MemoryItem] = getattr(data, bucket)
+            for row in rows:
+                if subject and row.subject != subject:
+                    continue
+                if status and row.status != status:
+                    continue
+                result.append(row)
+        return result
+
+    def stats(self) -> Dict[str, Any]:
+        data = self.load()
+        by_category: Dict[str, int] = {}
+        active = 0
+        outdated = 0
+        contradicted = 0
+        tentative = 0
+        for category, bucket in CATEGORY_TO_BUCKET.items():
+            rows: List[MemoryItem] = getattr(data, bucket)
+            by_category[category] = len(rows)
+            for row in rows:
+                if row.status == "active":
+                    active += 1
+                elif row.status == "outdated":
+                    outdated += 1
+                elif row.status == "contradicted":
+                    contradicted += 1
+                elif row.status == "tentative":
+                    tentative += 1
+        return {
+            "total": data.count_items(),
+            "active": active,
+            "outdated": outdated,
+            "contradicted": contradicted,
+            "tentative": tentative,
+            "by_category": by_category,
+            "path": str(self.path),
+        }
+
+    def dump(self) -> Dict[str, Any]:
+        return self.load().to_dict()
+
+    def conflicts(self) -> List[Dict[str, Any]]:
+        data = self.load()
+        conflicts: List[Dict[str, Any]] = []
+        for category, bucket in CATEGORY_TO_BUCKET.items():
+            key_count: Dict[tuple[Any, ...], int] = {}
+            rows: List[MemoryItem] = getattr(data, bucket)
+            for row in rows:
+                if row.status != "active":
+                    continue
+                key = self._key_for(row)
+                key_count[key] = key_count.get(key, 0) + 1
+            for key, cnt in key_count.items():
+                if cnt > 1:
+                    conflicts.append({"category": category, "key": list(key), "active_items": cnt})
+        return conflicts
+
+
+def main() -> None:
+    import argparse
+    import sys
+
+    parser = argparse.ArgumentParser(description="Memory Scratchpad CLI")
+    parser.add_argument("--project-root", type=str, help="项目根目录")
+    sub = parser.add_subparsers(dest="command", required=True)
+
+    sub.add_parser("stats")
+    p_query = sub.add_parser("query")
+    p_query.add_argument("--category", type=str, default=None)
+    p_query.add_argument("--subject", type=str, default=None)
+    p_query.add_argument("--status", type=str, default="active")
+    sub.add_parser("dump")
+    sub.add_parser("conflicts")
+
+    p_update = sub.add_parser("update")
+    p_update.add_argument("--chapter", type=int, required=True)
+    p_update.add_argument("--data", required=True, help="章节结构化结果 JSON")
+
+    sub.add_parser("bootstrap")
+
+    args = parser.parse_args(normalize_global_project_root(sys.argv[1:]))
+
+    config = None
+    if args.project_root:
+        from project_locator import resolve_project_root
+
+        resolved_root = resolve_project_root(args.project_root)
+        config = DataModulesConfig.from_project_root(resolved_root)
+
+    manager = ScratchpadManager(config)
+
+    if args.command == "stats":
+        print_success(manager.stats(), message="memory_stats")
+        return
+    if args.command == "dump":
+        print_success(manager.dump(), message="memory_dump")
+        return
+    if args.command == "conflicts":
+        print_success(manager.conflicts(), message="memory_conflicts")
+        return
+    if args.command == "query":
+        rows = [row.to_dict() for row in manager.query(args.category, args.subject, args.status)]
+        print_success(rows, message="memory_query")
+        return
+    if args.command == "update":
+        from .writer import MemoryWriter
+
+        payload = load_json_arg(args.data)
+        writer = MemoryWriter(config or get_config())
+        result = writer.update_from_chapter_result(args.chapter, payload)
+        print_success(result, message="memory_updated")
+        return
+    if args.command == "bootstrap":
+        from .bootstrap import bootstrap_from_index
+
+        result = bootstrap_from_index(config or get_config())
+        print_success(result, message="memory_bootstrapped")
+        return
+
+    print_error("UNKNOWN_COMMAND", "未知命令", suggestion="请查看 --help")

+ 223 - 0
webnovel-writer/scripts/data_modules/memory/writer.py

@@ -0,0 +1,223 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+章节结果 -> 长期记忆项映射。
+"""
+from __future__ import annotations
+
+import hashlib
+from typing import Any, Dict, List
+
+from ..config import DataModulesConfig, get_config
+from .schema import MemoryItem
+from .store import ScratchpadManager
+
+
+class MemoryWriter:
+    def __init__(self, config: DataModulesConfig | None = None):
+        self.config = config or get_config()
+        self.store = ScratchpadManager(self.config)
+
+    def _item_id(self, category: str, subject: str, field: str, chapter: int) -> str:
+        raw = f"{category}|{subject}|{field}|{chapter}"
+        digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
+        return f"mem-{category}-{digest}"
+
+    def _upsert(self, item: MemoryItem, stats: Dict[str, Any]) -> None:
+        result = self.store.upsert_item(item)
+        stats["items_added"] += int(result.get("added", 0))
+        stats["items_updated"] += int(result.get("updated", 0))
+        stats["items_outdated"] += int(result.get("outdated", 0))
+
+    def update_from_chapter_result(self, chapter: int, result: Dict[str, Any]) -> Dict[str, Any]:
+        stats: Dict[str, Any] = {
+            "chapter": int(chapter),
+            "items_added": 0,
+            "items_updated": 0,
+            "items_outdated": 0,
+            "warnings": [],
+        }
+
+        # Stage 2: 零成本结构化映射
+        for change in result.get("state_changes", []) or []:
+            entity_id = str(change.get("entity_id", "") or "").strip()
+            field = str(change.get("field", "") or "").strip()
+            if not entity_id or not field:
+                continue
+            item = MemoryItem(
+                id=self._item_id("character_state", entity_id, field, chapter),
+                layer="semantic",
+                category="character_state",
+                subject=entity_id,
+                field=field,
+                value=str(change.get("new", "") or ""),
+                payload={"old_value": change.get("old")},
+                source_chapter=int(chapter),
+                evidence=[f"state_change:{entity_id}:{field}:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        for entity in result.get("entities_new", []) or []:
+            entity_id = str(entity.get("suggested_id") or entity.get("id") or "").strip()
+            name = str(entity.get("name", "") or "").strip()
+            if not entity_id:
+                continue
+            item = MemoryItem(
+                id=self._item_id("character_state", entity_id, "first_seen", chapter),
+                layer="semantic",
+                category="character_state",
+                subject=entity_id,
+                field="first_seen",
+                value=name,
+                payload={"tier": entity.get("tier"), "type": entity.get("type")},
+                source_chapter=int(chapter),
+                evidence=[f"entity_new:{entity_id}:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        for rel in result.get("relationships_new", []) or []:
+            from_entity = str(rel.get("from") or rel.get("from_entity") or "").strip()
+            to_entity = str(rel.get("to") or rel.get("to_entity") or "").strip()
+            rel_type = str(rel.get("type", "") or "").strip()
+            if not from_entity or not to_entity:
+                continue
+            item = MemoryItem(
+                id=self._item_id("relationship", from_entity, to_entity, chapter),
+                layer="semantic",
+                category="relationship",
+                subject=from_entity,
+                field=to_entity,
+                value=rel_type,
+                payload={"description": rel.get("description", ""), "to_entity": to_entity},
+                source_chapter=int(chapter),
+                evidence=[f"relationship:{from_entity}:{to_entity}:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        chapter_meta = result.get("chapter_meta") or {}
+        hook = chapter_meta.get("hook")
+        if isinstance(hook, dict):
+            hook_content = str(hook.get("content", "") or "").strip()
+            if hook_content:
+                item = MemoryItem(
+                    id=self._item_id("story_fact", "chapter_hook", str(chapter), chapter),
+                    layer="semantic",
+                    category="story_fact",
+                    subject="chapter_hook",
+                    field=str(chapter),
+                    value=hook_content,
+                    payload={"hook_type": hook.get("type"), "strength": hook.get("strength")},
+                    source_chapter=int(chapter),
+                    evidence=[f"chapter_meta:hook:{chapter}"],
+                )
+                self._upsert(item, stats)
+        elif isinstance(hook, str) and hook.strip():
+            item = MemoryItem(
+                id=self._item_id("story_fact", "chapter_hook", str(chapter), chapter),
+                layer="semantic",
+                category="story_fact",
+                subject="chapter_hook",
+                field=str(chapter),
+                value=hook.strip(),
+                payload={},
+                source_chapter=int(chapter),
+                evidence=[f"chapter_meta:hook:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        # Stage 4: Data Agent 深度提取扩展
+        memory_facts = result.get("memory_facts") or {}
+        if isinstance(memory_facts, dict):
+            self._apply_memory_facts(chapter, memory_facts, stats)
+
+        return stats
+
+    def _apply_memory_facts(self, chapter: int, memory_facts: Dict[str, Any], stats: Dict[str, Any]) -> None:
+        timeline_events = memory_facts.get("timeline_events") or []
+        for row in timeline_events:
+            if not isinstance(row, dict):
+                continue
+            event = str(row.get("event", "") or "").strip()
+            if not event:
+                continue
+            source_chapter = int(row.get("chapter") or chapter)
+            item = MemoryItem(
+                id=self._item_id("timeline", event, str(source_chapter), chapter),
+                layer="semantic",
+                category="timeline",
+                subject=event[:64],
+                field="event",
+                value=event,
+                payload={"time_hint": row.get("time_hint"), "event_type": row.get("event_type")},
+                source_chapter=source_chapter,
+                evidence=[f"memory_facts:timeline:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        world_rules = memory_facts.get("world_rules") or []
+        for row in world_rules:
+            if not isinstance(row, dict):
+                continue
+            rule = str(row.get("rule", "") or "").strip()
+            if not rule:
+                continue
+            subject = str(row.get("domain", "") or "").strip() or str(row.get("scope", "") or "").strip() or "global"
+            field = str(row.get("field", "") or "").strip() or rule[:32]
+            item = MemoryItem(
+                id=self._item_id("world_rule", subject, field, chapter),
+                layer="semantic",
+                category="world_rule",
+                subject=subject,
+                field=field,
+                value=rule,
+                payload={"scope": row.get("scope"), "rule_text": rule},
+                source_chapter=int(chapter),
+                evidence=[f"memory_facts:world_rule:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        open_loops = memory_facts.get("open_loops") or []
+        for row in open_loops:
+            if not isinstance(row, dict):
+                continue
+            content = str(row.get("content", "") or "").strip()
+            if not content:
+                continue
+            item = MemoryItem(
+                id=self._item_id("open_loop", content, "status", chapter),
+                layer="semantic",
+                category="open_loop",
+                subject=content,
+                field="status",
+                value=content,
+                payload={
+                    "urgency": row.get("urgency"),
+                    "planted_chapter": row.get("planted_chapter"),
+                    "expected_payoff": row.get("expected_payoff"),
+                    "status": row.get("status"),
+                },
+                source_chapter=int(chapter),
+                evidence=[f"memory_facts:open_loop:{chapter}"],
+            )
+            self._upsert(item, stats)
+
+        reader_promises = memory_facts.get("reader_promises") or []
+        for row in reader_promises:
+            if not isinstance(row, dict):
+                continue
+            content = str(row.get("content", "") or "").strip()
+            if not content:
+                continue
+            item = MemoryItem(
+                id=self._item_id("reader_promise", content, "promise", chapter),
+                layer="semantic",
+                category="reader_promise",
+                subject=content,
+                field="promise",
+                value=content,
+                payload={"promise_type": row.get("type"), "target": row.get("target")},
+                source_chapter=int(chapter),
+                evidence=[f"memory_facts:reader_promise:{chapter}"],
+            )
+            self._upsert(item, stats)
+

+ 67 - 0
webnovel-writer/scripts/data_modules/schemas.py

@@ -64,6 +64,53 @@ class UncertainMention(BaseModel):
     adopted: Optional[str] = None
 
 
+class TimelineEvent(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+    event: str
+    chapter: Optional[int] = None
+    time_hint: Optional[str] = None
+    event_type: Optional[str] = None
+
+
+class WorldRule(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+    rule: str
+    scope: Optional[str] = None
+    domain: Optional[str] = None
+    field: Optional[str] = None
+
+
+class OpenLoop(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+    content: str
+    status: Optional[str] = None
+    urgency: Optional[float] = None
+    planted_chapter: Optional[int] = None
+    expected_payoff: Optional[str] = None
+
+
+class ReaderPromise(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+    content: str
+    type: Optional[str] = None
+    target: Optional[str] = None
+
+
+class MemoryFacts(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+    timeline_events: List[TimelineEvent] = Field(default_factory=list)
+    world_rules: List[WorldRule] = Field(default_factory=list)
+    open_loops: List[OpenLoop] = Field(default_factory=list)
+    reader_promises: List[ReaderPromise] = Field(default_factory=list)
+
+
+
+
 class DataAgentOutput(BaseModel):
     model_config = ConfigDict(extra="allow")
 
@@ -74,6 +121,7 @@ class DataAgentOutput(BaseModel):
     scenes_chunked: int = 0
     uncertain: List[UncertainMention] = Field(default_factory=list)
     warnings: List[str] = Field(default_factory=list)
+    memory_facts: Optional[MemoryFacts] = None
 
 
 class ErrorSchema(BaseModel):
@@ -102,6 +150,9 @@ def normalize_data_agent_output(payload: Dict[str, Any]) -> Dict[str, Any]:
     if not isinstance(payload, dict):
         return {}
 
+    # 操作副本,避免修改调用方原始数据
+    payload = dict(payload)
+
     def _ensure_list(key: str):
         value = payload.get(key)
         if value is None:
@@ -121,5 +172,21 @@ def normalize_data_agent_output(payload: Dict[str, Any]) -> Dict[str, Any]:
     ]:
         _ensure_list(key)
 
+    memory_facts = payload.get("memory_facts")
+    if memory_facts is None:
+        payload["memory_facts"] = {}
+    elif not isinstance(memory_facts, dict):
+        payload["memory_facts"] = {}
+    else:
+        memory_facts = dict(memory_facts)
+        payload["memory_facts"] = memory_facts
+        for key in ["timeline_events", "world_rules", "open_loops", "reader_promises"]:
+            value = memory_facts.get(key)
+            if value is None:
+                memory_facts[key] = []
+            elif not isinstance(value, list):
+                memory_facts[key] = [value]
+
     payload.setdefault("scenes_chunked", 0)
+
     return payload

+ 9 - 6
webnovel-writer/scripts/data_modules/snapshot_manager.py

@@ -21,7 +21,7 @@ except ImportError:  # pragma: no cover
     # 当以 python -m scripts.data_modules... 形式运行
     from scripts.security_utils import atomic_write_json
 
-SNAPSHOT_VERSION = "1.2"
+SNAPSHOT_VERSION = "1.3"
 
 
 class SnapshotVersionMismatch(RuntimeError):
@@ -73,11 +73,14 @@ class SnapshotManager:
         with lock:
             if not path.exists():
                 return None
-            data = json.loads(path.read_text(encoding="utf-8"))
-        version = str(data.get("version", ""))
-        if version != self.version:
-            raise SnapshotVersionMismatch(self.version, version)
-        return data
+            try:
+                data = json.loads(path.read_text(encoding="utf-8"))
+            except (json.JSONDecodeError, OSError):
+                return None
+            version = str(data.get("version", ""))
+            if version != self.version:
+                raise SnapshotVersionMismatch(self.version, version)
+            return data
 
     def delete_snapshot(self, chapter: int) -> bool:
         path = self._snapshot_path(chapter)

+ 21 - 2
webnovel-writer/scripts/data_modules/state_manager.py

@@ -32,6 +32,7 @@ from .observability import safe_append_perf_timing, safe_log_tool_call
 
 logger = logging.getLogger(__name__)
 
+
 try:
     # 当 scripts 目录在 sys.path 中(常见:从 scripts/ 运行)
     from security_utils import atomic_write_json, read_json_safe
@@ -1079,6 +1080,7 @@ class StateManager:
         # 处理消歧不确定项(不影响实体写入,但必须对 Writer 可见)
         warnings.extend(self._record_disambiguation(chapter, result.get("uncertain", [])))
 
+
         # 写入 chapter_meta(钩子/模式/结束状态)
         chapter_meta = result.get("chapter_meta")
         if isinstance(chapter_meta, dict):
@@ -1086,13 +1088,22 @@ class StateManager:
             self._state.setdefault("chapter_meta", {})
             self._state["chapter_meta"][meta_key] = chapter_meta
             self._pending_chapter_meta[meta_key] = chapter_meta
-
         # 更新进度
         self.update_progress(chapter)
 
         # 同步主角状态(entities_v3 → protagonist_state)
         self.sync_protagonist_from_entity()
 
+        # 长期记忆写入(best-effort,不阻断主流程)
+        try:
+            from .memory.writer import MemoryWriter
+
+            writer = MemoryWriter(self.config)
+            mem_result = writer.update_from_chapter_result(chapter, result)
+            logger.info("memory_write: %s", mem_result)
+        except Exception as exc:
+            logger.warning("memory_write_failed: %s", exc)
+
         return warnings
 
     # ==================== 导出 ====================
@@ -1260,7 +1271,15 @@ def main():
         from project_locator import resolve_project_root
         from .config import DataModulesConfig
 
-        resolved_root = resolve_project_root(args.project_root)
+        try:
+            resolved_root = resolve_project_root(args.project_root)
+        except FileNotFoundError as exc:
+            print_error(
+                "INVALID_PROJECT_ROOT",
+                str(exc),
+                suggestion="请传入包含 .webnovel/state.json 的书项目根目录,或先通过 webnovel.py 解析 project_root。",
+            )
+            raise SystemExit(1) from exc
         config = DataModulesConfig.from_project_root(resolved_root)
 
     manager = StateManager(config)

+ 39 - 0
webnovel-writer/scripts/data_modules/state_validator.py

@@ -51,6 +51,28 @@ _PATTERN_FIELDS = [
 _PATTERN_SPLIT_RE = re.compile(r"[、,,/|+;;。]+")
 
 
+_PLOT_LIST_FIELDS = ("cpns", "mandatory_nodes", "prohibitions")
+
+
+def _normalize_string_list(raw_value: Any) -> List[str]:
+    items: List[str] = []
+    if isinstance(raw_value, list):
+        source = raw_value
+    elif isinstance(raw_value, str):
+        source = split_patterns(raw_value)
+    else:
+        return []
+
+    seen = set()
+    for item in source:
+        text = str(item).strip()
+        if not text or text in seen:
+            continue
+        seen.add(text)
+        items.append(text)
+    return items
+
+
 def to_positive_int(value: Any) -> Optional[int]:
     if value is None or isinstance(value, bool):
         return None
@@ -203,6 +225,23 @@ def normalize_chapter_meta_entry(entry: Mapping[str, Any]) -> Dict[str, Any]:
     if merged_patterns:
         normalized["coolpoint_patterns"] = merged_patterns
 
+    plot_structure = entry.get("plot_structure")
+    if isinstance(plot_structure, Mapping):
+        normalized_plot_structure = dict(plot_structure)
+        cbn = str(plot_structure.get("cbn") or "").strip()
+        cen = str(plot_structure.get("cen") or "").strip()
+        if cbn:
+            normalized_plot_structure["cbn"] = cbn
+        if cen:
+            normalized_plot_structure["cen"] = cen
+        for field_name in _PLOT_LIST_FIELDS:
+            normalized_values = _normalize_string_list(plot_structure.get(field_name))
+            if normalized_values:
+                normalized_plot_structure[field_name] = normalized_values
+            elif field_name in normalized_plot_structure:
+                normalized_plot_structure[field_name] = []
+        normalized["plot_structure"] = normalized_plot_structure
+
     return normalized
 
 

+ 1 - 0
webnovel-writer/scripts/data_modules/tests/test_config.py

@@ -15,6 +15,7 @@ def test_config_paths_and_defaults(tmp_path):
     assert cfg.project_root == tmp_path
     assert cfg.webnovel_dir.name == ".webnovel"
     assert cfg.state_file.name == "state.json"
+    assert cfg.scratchpad_file.name == "memory_scratchpad.json"
     assert cfg.index_db.name == "index.db"
     assert cfg.rag_db.name == "rag.db"
     assert cfg.vector_db.name == "vectors.db"

+ 152 - 0
webnovel-writer/scripts/data_modules/tests/test_context_manager.py

@@ -95,6 +95,70 @@ def test_context_manager_build_and_filter(temp_project):
     assert any(c.get("entity_id") == "xiaoyan" for c in characters)
     assert not any(c.get("entity_id") == "bad" for c in characters)
     assert payload["sections"]["preferences"]["content"].get("tone") == "热血"
+    assert "long_term_memory" in payload["sections"]
+
+
+def test_context_manager_uses_memory_orchestrator_for_working_when_enabled(temp_project, monkeypatch):
+    state = {
+        "protagonist_state": {"name": "旧快照"},
+        "chapter_meta": {},
+        "disambiguation_warnings": [],
+        "disambiguation_pending": [],
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+    temp_project.context_use_memory_orchestrator = True
+
+    def _fake_pack(self, chapter, task_type="write"):
+        return {
+            "working_memory": [
+                {"layer": "working", "source": "outline", "chapter": chapter, "content": "FAKE_OUTLINE"},
+                {
+                    "layer": "working",
+                    "source": "state_export",
+                    "chapter": chapter,
+                    "content": {"protagonist_state": {"name": "新快照"}},
+                },
+                {"layer": "working", "source": "summary", "chapter": chapter - 1, "content": "FAKE_SUMMARY"},
+            ],
+            "episodic_memory": [],
+            "semantic_memory": [],
+            "long_term_facts": [],
+            "active_constraints": [],
+            "recent_changes": [],
+            "warnings": [],
+            "stats": {"total": 0, "injected": 0, "filtered": 0, "conflicts": 0},
+        }
+
+    monkeypatch.setattr("data_modules.memory.orchestrator.MemoryOrchestrator.build_memory_pack", _fake_pack)
+    manager = ContextManager(temp_project)
+    payload = manager.build_context(1, use_snapshot=False, save_snapshot=False)
+    core = payload["sections"]["core"]["content"]
+    long_term_memory = payload["sections"]["long_term_memory"]["content"]
+
+    assert "working_memory" in long_term_memory
+    assert core["chapter_outline"] == "FAKE_OUTLINE"
+    assert core["protagonist_snapshot"] == {"name": "新快照"}
+    assert core["recent_summaries"] == [{"chapter": 0, "summary": "FAKE_SUMMARY"}]
+
+
+def test_context_manager_skips_memory_orchestrator_when_disabled(temp_project, monkeypatch):
+    state = {
+        "protagonist_state": {"name": "萧炎"},
+        "chapter_meta": {},
+        "disambiguation_warnings": [],
+        "disambiguation_pending": [],
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+    temp_project.context_use_memory_orchestrator = False
+
+    def _boom(self, chapter, task_type="write"):
+        raise AssertionError("context_use_memory_orchestrator=false 时不应调用 orchestrator")
+
+    monkeypatch.setattr("data_modules.memory.orchestrator.MemoryOrchestrator.build_memory_pack", _boom)
+    manager = ContextManager(temp_project)
+    payload = manager.build_context(1, use_snapshot=False, save_snapshot=False)
+
+    assert payload["sections"]["long_term_memory"]["content"] == {}
 
 
 def test_context_manager_loads_volume_outline_file(temp_project):
@@ -157,6 +221,60 @@ def test_context_snapshot_respects_template(temp_project):
     assert battle_payload.get("template") == "battle"
 
 
+def test_context_snapshot_invalidates_legacy_version(temp_project):
+    state = {
+        "project": {"genre": "xuanhuan"},
+        "protagonist_state": {"name": "萧炎"},
+        "chapter_meta": {},
+        "disambiguation_warnings": [],
+        "disambiguation_pending": [],
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+    temp_project.outline_dir.mkdir(parents=True, exist_ok=True)
+    (temp_project.outline_dir / "第1卷-详细大纲.md").write_text(
+        """### 第4章:试炼
+CBN:进入试炼场
+CPNs:
+- 观察规则
+CEN:决定将计就计
+""",
+        encoding="utf-8",
+    )
+
+    snapshot_path = temp_project.webnovel_dir / "context_snapshots" / "ch0004.json"
+    snapshot_path.parent.mkdir(parents=True, exist_ok=True)
+    snapshot_path.write_text(
+        json.dumps(
+            {
+                "version": "1.2",
+                "chapter": 4,
+                "saved_at": "2026-03-01T00:00:00+00:00",
+                "meta": {"template": "plot"},
+                "payload": {
+                    "meta": {"chapter": 4},
+                    "sections": {
+                        "core": {
+                            "content": {"chapter_outline": "旧快照"},
+                            "text": "{}",
+                            "budget": 1000,
+                        }
+                    },
+                    "template": "plot",
+                    "weights": {},
+                },
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    manager = ContextManager(temp_project)
+    payload = manager.build_context(4, template="plot", use_snapshot=True, save_snapshot=False)
+
+    assert payload["sections"]["core"]["content"]["chapter_outline"] != "旧快照"
+    assert payload["sections"]["plot_structure"]["content"]["cbn"] == "进入试炼场"
+
+
 def test_context_manager_applies_ranker_and_contract_meta(temp_project):
     state = {
         "protagonist_state": {"name": "萧炎"},
@@ -655,3 +773,37 @@ def test_context_manager_genre_profile_prefers_project_over_project_info(temp_pr
 
     assert profile.get("genre_raw") == "xuanhuan"
     assert profile.get("genre") == "xuanhuan"
+
+
+def test_context_manager_includes_plot_structure_when_outline_has_nodes(temp_project):
+    state = {
+        "project": {"genre": "xuanhuan"},
+        "protagonist_state": {"name": "萧炎"},
+        "chapter_meta": {},
+        "disambiguation_warnings": [],
+        "disambiguation_pending": [],
+    }
+    temp_project.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+    temp_project.outline_dir.mkdir(parents=True, exist_ok=True)
+    (temp_project.outline_dir / "第1卷-详细大纲.md").write_text(
+        """### 第4章:试炼
+CBN:进入试炼场
+CPNs:
+- 观察规则
+- 发现陷阱
+CEN:决定将计就计
+必须覆盖节点:发现陷阱
+本章禁区:不能直接翻脸
+""",
+        encoding="utf-8",
+    )
+
+    manager = ContextManager(temp_project)
+    payload = manager.build_context(4, use_snapshot=False, save_snapshot=False)
+
+    plot_structure = payload["sections"]["plot_structure"]["content"]
+    assert plot_structure.get("cbn") == "进入试炼场"
+    assert plot_structure.get("cpns") == ["观察规则", "发现陷阱"]
+    assert plot_structure.get("cen") == "决定将计就计"
+    assert plot_structure.get("mandatory_nodes") == ["发现陷阱"]
+    assert plot_structure.get("prohibitions") == ["不能直接翻脸"]

+ 518 - 0
webnovel-writer/scripts/data_modules/tests/test_coverage_boost.py

@@ -0,0 +1,518 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+补全 store CLI, compactor 边界, cli_args 解析, webnovel 路由 的测试覆盖。
+"""
+
+import json
+import sys
+from io import StringIO
+from pathlib import Path
+
+import pytest
+
+from data_modules.config import DataModulesConfig
+from data_modules.memory.schema import MemoryItem, ScratchpadData
+from data_modules.memory.store import ScratchpadManager
+from data_modules.memory.compactor import compact_scratchpad, _key_for, _is_resolved_open_loop
+from data_modules.cli_args import normalize_global_project_root, load_json_arg, _extract_flag_value
+
+
+# ── helpers ──────────────────────────────────────────────────────────────
+
+def _cfg(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    if not cfg.state_file.exists():
+        cfg.state_file.write_text("{}", encoding="utf-8")
+    return cfg
+
+
+def _make_item(id, category="character_state", subject="x", field="f", value="v", chapter=1, **kw):
+    return MemoryItem(
+        id=id, layer="semantic", category=category,
+        subject=subject, field=field, value=value,
+        source_chapter=chapter, **kw,
+    )
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 1. cli_args 补全
+# ═══════════════════════════════════════════════════════════════════════
+
+def test_extract_flag_value_equals_form():
+    value, rest = _extract_flag_value(["cmd", "--project-root=/my/path", "sub"], "--project-root")
+    assert value == "/my/path"
+    assert rest == ["cmd", "sub"]
+
+
+def test_extract_flag_value_dangling_flag():
+    value, rest = _extract_flag_value(["--project-root"], "--project-root")
+    assert value is None
+    assert rest == ["--project-root"]
+
+
+def test_extract_flag_value_last_wins():
+    value, rest = _extract_flag_value(
+        ["--project-root", "first", "cmd", "--project-root", "second"],
+        "--project-root",
+    )
+    assert value == "second"
+    assert rest == ["cmd"]
+
+
+def test_normalize_global_project_root_no_flag():
+    argv = ["cmd", "--other", "val"]
+    assert normalize_global_project_root(argv) is argv
+
+
+def test_load_json_arg_from_file(tmp_path):
+    f = tmp_path / "data.json"
+    f.write_text('{"a":1}', encoding="utf-8")
+    result = load_json_arg(f"@{f}")
+    assert result == {"a": 1}
+
+
+def test_load_json_arg_from_stdin(monkeypatch):
+    monkeypatch.setattr(sys, "stdin", StringIO('{"b":2}'))
+    result = load_json_arg("@-")
+    assert result == {"b": 2}
+
+
+def test_load_json_arg_empty_at_raises():
+    with pytest.raises(ValueError, match="without path"):
+        load_json_arg("@")
+
+
+def test_load_json_arg_none_raises():
+    with pytest.raises(ValueError, match="missing"):
+        load_json_arg(None)
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 2. compactor 边界补全
+# ═══════════════════════════════════════════════════════════════════════
+
+def test_key_for_unknown_category_falls_back_to_id():
+    item = _make_item("x1", category="unknown_cat")
+    assert _key_for(item) == ("x1",)
+
+
+def test_is_resolved_open_loop_various_statuses():
+    base = dict(id="ol1", layer="semantic", category="open_loop", subject="x", field="status", value="x", source_chapter=1)
+    assert _is_resolved_open_loop(MemoryItem(**base, payload={"status": "resolved"})) is True
+    assert _is_resolved_open_loop(MemoryItem(**base, payload={"status": "payoff"})) is True
+    assert _is_resolved_open_loop(MemoryItem(**base, payload={"status": "active"})) is False
+    assert _is_resolved_open_loop(MemoryItem(**base, payload=None)) is False
+
+    non_loop = _make_item("cs1", category="character_state")
+    assert _is_resolved_open_loop(non_loop) is False
+
+
+def test_compactor_dedup_outdated_keeps_latest():
+    """步骤1: 同key的outdated只保留最新一条。"""
+    data = ScratchpadData.empty()
+    # 3 items: 2 outdated (同key) + 1 active → dedup后变2 items, <= max_items=3
+    data.character_state = [
+        _make_item("a1", subject="x", field="realm", value="v1", status="outdated", updated_at="2026-01-01T00:00:00"),
+        _make_item("a2", subject="x", field="realm", value="v2", status="outdated", updated_at="2026-02-01T00:00:00"),
+        _make_item("a3", subject="x", field="realm", value="v3", status="active"),
+    ]
+    # 需1个额外item让总数=4 > max_items=3,触发压缩入口
+    data.world_rules.append(_make_item("wr0", category="world_rule", subject="r0", field="f0", value="v0", chapter=1))
+    result = compact_scratchpad(data, max_items=3)
+    outdated = [r for r in result.character_state if r.status == "outdated"]
+    # dedup去掉a1,保留a2(更新),压缩后总数=3(a2+a3+wr0)刚好 <= max_items
+    assert len(outdated) == 1
+    assert outdated[0].value == "v2"
+
+
+def test_compactor_cleans_resolved_open_loops():
+    """步骤2: 已resolved的open_loop被清除。"""
+    data = ScratchpadData.empty()
+    data.open_loops = [
+        _make_item("ol1", category="open_loop", subject="伏笔A", field="status", value="A", payload={"status": "resolved"}),
+        _make_item("ol2", category="open_loop", subject="伏笔B", field="status", value="B", payload={"status": "active"}),
+    ]
+    # 补2个填充项让总数=4 > max_items=3
+    data.world_rules.append(_make_item("wr0", category="world_rule", subject="r0", field="f0", value="v0", chapter=1))
+    data.world_rules.append(_make_item("wr1", category="world_rule", subject="r1", field="f1", value="v1", chapter=1))
+    result = compact_scratchpad(data, max_items=3)
+    loop_subjects = [r.subject for r in result.open_loops]
+    assert "伏笔A" not in loop_subjects
+    assert "伏笔B" in loop_subjects
+
+
+def test_compactor_replaces_existing_timeline_summary():
+    data = ScratchpadData.empty()
+    # pre-existing summary
+    data.story_facts = [
+        _make_item("sf-old", category="story_fact", subject="timeline_summary", field="<=ch5", value="旧摘要"),
+    ]
+    # old timeline entries (>50 chapters from latest)
+    for i in range(3):
+        data.timeline.append(_make_item(f"t-old-{i}", category="timeline", subject=f"旧事件{i}", field="event", value=f"旧事件{i}", chapter=i+1))
+    # fresh timeline
+    data.timeline.append(_make_item("t-fresh", category="timeline", subject="新事件", field="event", value="新事件", chapter=60))
+    # pad to exceed max
+    for i in range(5):
+        data.world_rules.append(_make_item(f"wr{i}", category="world_rule", subject=f"rule{i}", field=f"f{i}", value=f"v{i}", chapter=60))
+
+    result = compact_scratchpad(data, max_items=6)
+    summaries = [r for r in result.story_facts if r.subject == "timeline_summary"]
+    assert len(summaries) <= 1
+    if summaries:
+        assert "旧事件" in summaries[0].value
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 3. store CLI 补全
+# ═══════════════════════════════════════════════════════════════════════
+
+def test_store_cli_stats(tmp_path, monkeypatch, capsys):
+    cfg = _cfg(tmp_path)
+    manager = ScratchpadManager(cfg)
+    manager.upsert_item(_make_item("c1"))
+
+    monkeypatch.setattr(sys, "argv", ["store", "--project-root", str(tmp_path), "stats"])
+    from data_modules.memory import store as store_module
+    store_module.main()
+    out = json.loads(capsys.readouterr().out)
+    assert out["status"] == "success"
+    assert out["data"]["total"] >= 1
+
+
+def test_store_cli_dump(tmp_path, monkeypatch, capsys):
+    cfg = _cfg(tmp_path)
+    manager = ScratchpadManager(cfg)
+    manager.upsert_item(_make_item("c1"))
+
+    monkeypatch.setattr(sys, "argv", ["store", "--project-root", str(tmp_path), "dump"])
+    from data_modules.memory import store as store_module
+    store_module.main()
+    out = json.loads(capsys.readouterr().out)
+    assert out["status"] == "success"
+
+
+def test_store_cli_conflicts(tmp_path, monkeypatch, capsys):
+    cfg = _cfg(tmp_path)
+    monkeypatch.setattr(sys, "argv", ["store", "--project-root", str(tmp_path), "conflicts"])
+    from data_modules.memory import store as store_module
+    store_module.main()
+    out = json.loads(capsys.readouterr().out)
+    assert out["status"] == "success"
+
+
+def test_store_cli_query(tmp_path, monkeypatch, capsys):
+    cfg = _cfg(tmp_path)
+    manager = ScratchpadManager(cfg)
+    manager.upsert_item(_make_item("c1", subject="hero", field="realm", value="斗者"))
+
+    monkeypatch.setattr(sys, "argv", [
+        "store", "--project-root", str(tmp_path),
+        "query", "--category", "character_state", "--subject", "hero",
+    ])
+    from data_modules.memory import store as store_module
+    store_module.main()
+    out = json.loads(capsys.readouterr().out)
+    assert out["status"] == "success"
+    assert len(out["data"]) >= 1
+
+
+def test_store_cli_update(tmp_path, monkeypatch, capsys):
+    cfg = _cfg(tmp_path)
+    payload = json.dumps({
+        "state_changes": [{"entity_id": "hero", "field": "realm", "old": "斗者", "new": "斗师"}],
+    })
+    monkeypatch.setattr(sys, "argv", [
+        "store", "--project-root", str(tmp_path),
+        "update", "--chapter", "5", "--data", payload,
+    ])
+    from data_modules.memory import store as store_module
+    store_module.main()
+    out = json.loads(capsys.readouterr().out)
+    assert out["status"] == "success"
+    assert out["data"]["items_added"] >= 0
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# 4. webnovel 路由补全
+# ═══════════════════════════════════════════════════════════════════════
+
+def _load_webnovel_module():
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+    import data_modules.webnovel as webnovel_module
+    return webnovel_module
+
+
+def test_webnovel_cmd_where(monkeypatch, tmp_path, capsys):
+    module = _load_webnovel_module()
+    book_root = tmp_path / "book"
+    monkeypatch.setattr(module, "_resolve_root", lambda _=None: book_root)
+    monkeypatch.setattr(sys, "argv", ["webnovel", "where"])
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert str(book_root) in capsys.readouterr().out
+
+
+def test_webnovel_passthrough_state(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    book_root = tmp_path / "book"
+    called = {}
+
+    def _fake_resolve(_=None):
+        return book_root
+
+    def _fake_run(mod_name, argv):
+        called["mod"] = mod_name
+        called["argv"] = list(argv)
+        return 0
+
+    monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
+    monkeypatch.setattr(module, "_run_data_module", _fake_run)
+    monkeypatch.setattr(sys, "argv", ["webnovel", "state", "get-progress"])
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert called["mod"] == "state_manager"
+    assert "--project-root" in called["argv"]
+    assert "get-progress" in called["argv"]
+
+
+def test_webnovel_passthrough_memory(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    book_root = tmp_path / "book"
+    called = {}
+
+    monkeypatch.setattr(module, "_resolve_root", lambda _=None: book_root)
+    monkeypatch.setattr(module, "_run_data_module", lambda m, a: (called.update(mod=m, argv=list(a)), 0)[1])
+    monkeypatch.setattr(sys, "argv", ["webnovel", "memory", "stats"])
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert called["mod"] == "memory.store"
+
+
+def test_webnovel_passthrough_workflow_script(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    book_root = tmp_path / "book"
+    called = {}
+
+    monkeypatch.setattr(module, "_resolve_root", lambda _=None: book_root)
+    monkeypatch.setattr(module, "_run_script", lambda s, a: (called.update(script=s, argv=list(a)), 0)[1])
+    monkeypatch.setattr(sys, "argv", ["webnovel", "workflow", "start", "--chapter", "10"])
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert called["script"] == "workflow_manager.py"
+    assert "--project-root" in called["argv"]
+
+
+def test_webnovel_strip_project_root_args():
+    module = _load_webnovel_module()
+    result = module._strip_project_root_args(["--project-root", "/a", "cmd", "--project-root=/b", "--other"])
+    assert result == ["cmd", "--other"]
+
+
+def test_webnovel_passthrough_rag(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    book_root = tmp_path / "book"
+    called = {}
+    monkeypatch.setattr(module, "_resolve_root", lambda _=None: book_root)
+    monkeypatch.setattr(module, "_run_data_module", lambda m, a: (called.update(mod=m), 0)[1])
+    monkeypatch.setattr(sys, "argv", ["webnovel", "rag", "search", "--query", "test"])
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert called["mod"] == "rag_adapter"
+
+
+def test_webnovel_passthrough_style(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    book_root = tmp_path / "book"
+    called = {}
+    monkeypatch.setattr(module, "_resolve_root", lambda _=None: book_root)
+    monkeypatch.setattr(module, "_run_data_module", lambda m, a: (called.update(mod=m), 0)[1])
+    monkeypatch.setattr(sys, "argv", ["webnovel", "style", "list"])
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert called["mod"] == "style_sampler"
+
+
+def test_webnovel_passthrough_entity(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    called = {}
+    monkeypatch.setattr(module, "_resolve_root", lambda _=None: tmp_path)
+    monkeypatch.setattr(module, "_run_data_module", lambda m, a: (called.update(mod=m), 0)[1])
+    monkeypatch.setattr(sys, "argv", ["webnovel", "entity", "process"])
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert called["mod"] == "entity_linker"
+
+
+def test_webnovel_passthrough_context(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    called = {}
+    monkeypatch.setattr(module, "_resolve_root", lambda _=None: tmp_path)
+    monkeypatch.setattr(module, "_run_data_module", lambda m, a: (called.update(mod=m), 0)[1])
+    monkeypatch.setattr(sys, "argv", ["webnovel", "context", "build"])
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert called["mod"] == "context_manager"
+
+
+def test_webnovel_passthrough_migrate(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    called = {}
+    monkeypatch.setattr(module, "_resolve_root", lambda _=None: tmp_path)
+    monkeypatch.setattr(module, "_run_data_module", lambda m, a: (called.update(mod=m), 0)[1])
+    monkeypatch.setattr(sys, "argv", ["webnovel", "migrate"])
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert called["mod"] == "migrate_state_to_sqlite"
+
+
+def test_webnovel_passthrough_status_script(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    called = {}
+    monkeypatch.setattr(module, "_resolve_root", lambda _=None: tmp_path)
+    monkeypatch.setattr(module, "_run_script", lambda s, a: (called.update(script=s), 0)[1])
+    monkeypatch.setattr(sys, "argv", ["webnovel", "status"])
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert called["script"] == "status_reporter.py"
+
+
+def test_webnovel_passthrough_update_state_script(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    called = {}
+    monkeypatch.setattr(module, "_resolve_root", lambda _=None: tmp_path)
+    monkeypatch.setattr(module, "_run_script", lambda s, a: (called.update(script=s), 0)[1])
+    monkeypatch.setattr(sys, "argv", ["webnovel", "update-state", "add-review"])
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert called["script"] == "update_state.py"
+
+
+def test_webnovel_passthrough_backup_script(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    called = {}
+    monkeypatch.setattr(module, "_resolve_root", lambda _=None: tmp_path)
+    monkeypatch.setattr(module, "_run_script", lambda s, a: (called.update(script=s), 0)[1])
+    monkeypatch.setattr(sys, "argv", ["webnovel", "backup"])
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert called["script"] == "backup_manager.py"
+
+
+def test_webnovel_passthrough_archive_script(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    called = {}
+    monkeypatch.setattr(module, "_resolve_root", lambda _=None: tmp_path)
+    monkeypatch.setattr(module, "_run_script", lambda s, a: (called.update(script=s), 0)[1])
+    monkeypatch.setattr(sys, "argv", ["webnovel", "archive"])
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert called["script"] == "archive_manager.py"
+
+
+def test_webnovel_remainder_strips_leading_double_dash(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+    called = {}
+    monkeypatch.setattr(module, "_resolve_root", lambda _=None: tmp_path)
+    monkeypatch.setattr(module, "_run_data_module", lambda m, a: (called.update(argv=list(a)), 0)[1])
+    monkeypatch.setattr(sys, "argv", ["webnovel", "index", "--", "get-core-entities"])
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    assert "get-core-entities" in called["argv"]
+    assert "--" not in called["argv"]
+
+
+def test_webnovel_cmd_use(monkeypatch, tmp_path, capsys):
+    module = _load_webnovel_module()
+    book_root = tmp_path / "book"
+    (book_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (book_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+
+    monkeypatch.setattr(module, "write_current_project_pointer", lambda pr, workspace_root=None: None)
+    monkeypatch.setattr(module, "update_global_registry_current_project", lambda workspace_root=None, project_root=None: None)
+    monkeypatch.setattr(sys, "argv", ["webnovel", "use", str(book_root)])
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    out = capsys.readouterr().out
+    assert "pointer" in out.lower() or "skipped" in out.lower()
+
+
+def test_webnovel_cmd_use_with_workspace_root(monkeypatch, tmp_path, capsys):
+    module = _load_webnovel_module()
+    book_root = tmp_path / "book"
+    workspace_root = tmp_path / "ws"
+
+    pointer_path = tmp_path / "pointer.txt"
+    reg_path = tmp_path / "registry.json"
+
+    monkeypatch.setattr(module, "write_current_project_pointer", lambda pr, workspace_root=None: pointer_path)
+    monkeypatch.setattr(module, "update_global_registry_current_project", lambda workspace_root=None, project_root=None: reg_path)
+    monkeypatch.setattr(sys, "argv", ["webnovel", "use", str(book_root), "--workspace-root", str(workspace_root)])
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    out = capsys.readouterr().out
+    assert str(pointer_path) in out
+    assert str(reg_path) in out
+
+
+def test_webnovel_run_script_missing_script():
+    module = _load_webnovel_module()
+    with pytest.raises(FileNotFoundError, match="未找到脚本"):
+        module._run_script("nonexistent_script_xyz.py", [])
+
+
+def test_webnovel_run_data_module_no_main():
+    module = _load_webnovel_module()
+    with pytest.raises(RuntimeError, match="缺少可调用的 main"):
+        module._run_data_module("schemas", [])  # schemas 没有 main()
+
+
+def test_webnovel_preflight_json_format(monkeypatch, tmp_path, capsys):
+    module = _load_webnovel_module()
+    project_root = tmp_path / "book"
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+
+    monkeypatch.setattr(sys, "argv", ["webnovel", "--project-root", str(project_root), "preflight", "--format", "json"])
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+    assert int(exc.value.code or 0) == 0
+    result = json.loads(capsys.readouterr().out)
+    assert result["ok"] is True or result["ok"] is False
+    assert "checks" in result
+
+
+def test_webnovel_resolve_root_fallback(monkeypatch):
+    module = _load_webnovel_module()
+    # _resolve_root with None should call resolve_project_root() without args
+    called = {}
+    monkeypatch.setattr(module, "resolve_project_root", lambda *a, **kw: (called.update(args=a), Path("/fake"))[1])
+    result = module._resolve_root(None)
+    assert result == Path("/fake")
+    assert called["args"] == ()
+

+ 182 - 0
webnovel-writer/scripts/data_modules/tests/test_data_modules.py

@@ -42,9 +42,19 @@ def temp_project():
     with tempfile.TemporaryDirectory() as tmpdir:
         config = DataModulesConfig.from_project_root(tmpdir)
         config.ensure_dirs()
+        _ensure_project_state(config)
         yield config
 
 
+
+
+def _ensure_project_state(config):
+    config.ensure_dirs()
+    state_path = config.webnovel_dir / "state.json"
+    if not state_path.exists():
+        state_path.write_text(json.dumps({}, ensure_ascii=False), encoding="utf-8")
+
+
 class TestEntityLinker:
     """实体链接器测试"""
 
@@ -1412,3 +1422,175 @@ class TestRAGAdapter:
 
 if __name__ == "__main__":
     pytest.main([__file__, "-v"])
+
+
+
+def test_aggregate_checker_results_cli(temp_project, monkeypatch, capsys):
+    def run_cli(args):
+        monkeypatch.setattr(sys, "argv", args)
+        import data_modules.index_manager as im
+        im.main()
+        out = capsys.readouterr().out
+        return json.loads(out)
+
+    payload = {
+        "consistency-checker": {
+            "agent": "consistency-checker",
+            "chapter": 12,
+            "overall_score": 90,
+            "pass": True,
+            "issues": [],
+            "metrics": {"violated_prohibitions": []},
+            "summary": "设定稳定",
+        },
+        "continuity-checker": {
+            "agent": "continuity-checker",
+            "chapter": 12,
+            "overall_score": 70,
+            "pass": True,
+            "issues": [
+                {
+                    "id": "PLOT_001",
+                    "type": "PLOT_NODE_MISSING",
+                    "severity": "high",
+                    "location": "第8段",
+                    "description": "缺少关键节点“决定隐忍”",
+                    "suggestion": "补一段主角压下冲动的决策过程",
+                }
+            ],
+            "metrics": {
+                "hit_nodes": ["发现异常"],
+                "missing_nodes": ["决定隐忍"],
+                "coverage_grade": "C",
+            },
+            "summary": "节点覆盖不足",
+        },
+    }
+
+    out = run_cli([
+        "index_manager",
+        "--project-root",
+        str(temp_project.project_root),
+        "aggregate-review-results",
+        "--chapter",
+        "12",
+        "--data",
+        json.dumps(payload, ensure_ascii=False),
+    ])
+
+    assert out["status"] == "success"
+    data = out["data"]
+    assert data["overall_score"] == 80.0
+    assert data["severity_counts"]["high"] == 1
+    assert data["dimension_scores"]["consistency"] == 90.0
+    assert data["dimension_scores"]["continuity"] == 70.0
+    assert data["selected_checkers"] == ["consistency-checker", "continuity-checker"]
+    assert data["issues"][0]["agent"] == "continuity-checker"
+
+
+
+def test_materialize_review_metrics_cli(temp_project, monkeypatch, capsys):
+    def run_cli(args):
+        monkeypatch.setattr(sys, "argv", args)
+        import data_modules.index_manager as im
+        im.main()
+        out = capsys.readouterr().out
+        return json.loads(out)
+
+    payload = {
+        "continuity-checker": {
+            "agent": "continuity-checker",
+            "chapter": 18,
+            "overall_score": 72,
+            "pass": True,
+            "issues": [
+                {
+                    "id": "TIME_001",
+                    "type": "TIMELINE_ISSUE",
+                    "severity": "high",
+                    "location": "第3段",
+                    "description": "时间线回跳",
+                    "suggestion": "补明确闪回标记",
+                }
+            ],
+            "metrics": {},
+            "summary": "存在时间线风险",
+        },
+        "consistency-checker": {
+            "agent": "consistency-checker",
+            "chapter": 18,
+            "overall_score": 88,
+            "pass": True,
+            "issues": [],
+            "metrics": {},
+            "summary": "设定稳定",
+        },
+    }
+
+    out = run_cli([
+        "index_manager",
+        "--project-root",
+        str(temp_project.project_root),
+        "materialize-review-metrics",
+        "--chapter",
+        "18",
+        "--report-file",
+        "审查报告/第18章.md",
+        "--data",
+        json.dumps(payload, ensure_ascii=False),
+    ])
+
+    assert out["status"] == "success"
+    data = out["data"]
+    assert data["start_chapter"] == 18
+    assert data["end_chapter"] == 18
+    assert data["overall_score"] == 80.0
+    assert data["severity_counts"]["high"] == 1
+    assert data["report_file"] == "审查报告/第18章.md"
+    assert data["notes"] == "selected_checkers=continuity-checker,consistency-checker | timeline_gate:block=True;count=1"
+
+
+def test_aggregate_checker_results_blocks_overall_pass_for_high_timeline_issue(temp_project, monkeypatch, capsys):
+    def run_cli(args):
+        monkeypatch.setattr(sys, "argv", args)
+        import data_modules.index_manager as im
+        im.main()
+        out = capsys.readouterr().out
+        return json.loads(out)
+
+    payload = {
+        "continuity-checker": {
+            "agent": "continuity-checker",
+            "chapter": 21,
+            "overall_score": 78,
+            "pass": True,
+            "issues": [
+                {
+                    "id": "TIME_002",
+                    "type": "TIMELINE_ISSUE",
+                    "severity": "high",
+                    "location": "第6段",
+                    "description": "事件时间回跳",
+                    "suggestion": "补时间锚点",
+                }
+            ],
+            "metrics": {},
+            "summary": "时间线阻断",
+        }
+    }
+
+    out = run_cli([
+        "index_manager",
+        "--project-root",
+        str(temp_project.project_root),
+        "aggregate-review-results",
+        "--chapter",
+        "21",
+        "--data",
+        json.dumps(payload, ensure_ascii=False),
+    ])
+
+    data = out["data"]
+    assert data["timeline_gate"]["blocked"] is True
+    assert data["overall"]["pass"] is False
+    assert data["overall"]["can_proceed"] is False

+ 2 - 0
webnovel-writer/scripts/data_modules/tests/test_entity_linker_cli.py

@@ -17,6 +17,8 @@ def temp_project(tmp_path):
     from data_modules.config import DataModulesConfig
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()
+    if not cfg.state_file.exists():
+        cfg.state_file.write_text("{}", encoding="utf-8")
     return cfg
 
 

+ 78 - 0
webnovel-writer/scripts/data_modules/tests/test_extract_chapter_context.py

@@ -161,6 +161,7 @@ def test_build_chapter_context_payload_includes_contract_sections(tmp_path):
     assert "rag_assist" in payload
     assert isinstance(payload["rag_assist"], dict)
     assert payload["rag_assist"].get("invoked") is False
+    assert "long_term_memory" in payload
 
 
 def test_render_text_contains_writing_guidance_section(tmp_path):
@@ -271,3 +272,80 @@ def test_render_text_contains_rag_assist_section_when_hits_exist(tmp_path):
     assert "- 模式: auto" in text
     assert "[graph_hybrid]" in text
     assert "萧炎与药老" in text
+
+
+def test_build_chapter_context_payload_includes_plot_structure(tmp_path):
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+    from extract_chapter_context import build_chapter_context_payload
+
+    webnovel_dir = tmp_path / ".webnovel"
+    webnovel_dir.mkdir(parents=True, exist_ok=True)
+    state = {
+        "project": {"genre": "xuanhuan"},
+        "progress": {"current_chapter": 5, "total_words": 15000},
+        "protagonist_state": {},
+        "chapter_meta": {},
+        "disambiguation_warnings": [],
+        "disambiguation_pending": [],
+    }
+    (webnovel_dir / "state.json").write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+
+    outline_dir = tmp_path / "大纲"
+    outline_dir.mkdir(parents=True, exist_ok=True)
+    (outline_dir / "第1卷-详细大纲.md").write_text(
+        """### 第5章:试炼开局
+CBN:萧炎抵达外院试炼场
+CPNs:
+- 导师宣布试炼规则
+- 萧炎发现规则被人做了手脚
+CEN:萧炎决定先隐忍观察
+必须覆盖节点:规则异常暴露、决定隐忍
+本章禁区:不能直接揭穿黑手
+""",
+        encoding="utf-8",
+    )
+
+    payload = build_chapter_context_payload(tmp_path, 5)
+    plot_structure = payload.get("plot_structure") or {}
+    assert plot_structure.get("cbn") == "萧炎抵达外院试炼场"
+    assert plot_structure.get("cpns") == ["导师宣布试炼规则", "萧炎发现规则被人做了手脚"]
+    assert plot_structure.get("cen") == "萧炎决定先隐忍观察"
+    assert plot_structure.get("mandatory_nodes") == ["规则异常暴露", "决定隐忍"]
+    assert plot_structure.get("prohibitions") == ["不能直接揭穿黑手"]
+
+
+def test_render_text_contains_plot_structure_section(tmp_path):
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+    from extract_chapter_context import _render_text
+
+    payload = {
+        "chapter": 8,
+        "outline": "测试大纲",
+        "previous_summaries": [],
+        "state_summary": "状态",
+        "context_contract_version": "v2",
+        "plot_structure": {
+            "cbn": "主角进入遗迹",
+            "cpns": ["发现石碑异常", "与守卫短暂交锋"],
+            "cen": "决定深入遗迹核心",
+            "mandatory_nodes": ["发现石碑异常"],
+            "prohibitions": ["不能提前拿到终极传承"],
+        },
+        "reader_signal": {},
+        "genre_profile": {},
+        "writing_guidance": {},
+        "rag_assist": {"invoked": False, "hits": []},
+    }
+
+    text = _render_text(payload)
+    assert "## 情节结构" in text
+    assert "- CBN: 主角进入遗迹" in text
+    assert "- CPN1: 发现石碑异常" in text
+    assert "- CEN: 决定深入遗迹核心" in text
+    assert "- 本章禁区: 不能提前拿到终极传承" in text

+ 78 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_bootstrap.py

@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.config import DataModulesConfig
+from data_modules.index_manager import EntityMeta, RelationshipMeta, StateChangeMeta, IndexManager
+from data_modules.memory.bootstrap import bootstrap_from_index
+from data_modules.memory.store import ScratchpadManager
+
+
+def _cfg(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_bootstrap_from_index_includes_state_changes_and_open_loops(tmp_path):
+    cfg = _cfg(tmp_path)
+    idx = IndexManager(cfg)
+    idx.upsert_entity(
+        EntityMeta(
+            id="xiaoyan",
+            type="角色",
+            canonical_name="萧炎",
+            current={"realm": "斗者"},
+            first_appearance=1,
+            last_appearance=2,
+        )
+    )
+    idx.record_state_change(
+        StateChangeMeta(
+            entity_id="xiaoyan",
+            field="realm",
+            old_value="斗者",
+            new_value="斗师",
+            reason="突破",
+            chapter=3,
+        )
+    )
+    idx.record_state_change(
+        StateChangeMeta(
+            entity_id="xiaoyan",
+            field="realm",
+            old_value="斗师",
+            new_value="大斗师",
+            reason="再突破",
+            chapter=8,
+        )
+    )
+    idx.upsert_relationship(
+        RelationshipMeta(
+            from_entity="xiaoyan",
+            to_entity="yaolao",
+            type="师徒",
+            description="授艺",
+            chapter=2,
+        )
+    )
+
+    summaries_dir = cfg.webnovel_dir / "summaries"
+    summaries_dir.mkdir(parents=True, exist_ok=True)
+    (summaries_dir / "ch0008.md").write_text(
+        "## 剧情摘要\n内容\n\n## 伏笔\n- 三年之约\n- 神秘玉佩的来历\n",
+        encoding="utf-8",
+    )
+
+    result = bootstrap_from_index(cfg)
+    assert result["items_created"] > 0
+    assert result["categories"].get("character_state", 0) >= 2
+    assert result["categories"].get("open_loop", 0) >= 2
+
+    store = ScratchpadManager(cfg)
+    active_realm = store.query(category="character_state", subject="xiaoyan", status="active")
+    assert any(item.field == "realm" and item.value == "大斗师" for item in active_realm)
+    outdated_realm = store.query(category="character_state", subject="xiaoyan", status="outdated")
+    assert any(item.field == "realm" and item.value == "斗师" for item in outdated_realm)
+    loops = store.query(category="open_loop", status="active")
+    assert any("三年之约" in item.value for item in loops)
+

+ 64 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_orchestrator.py

@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.config import DataModulesConfig
+from data_modules.memory.orchestrator import MemoryOrchestrator
+from data_modules.memory.schema import MemoryItem
+from data_modules.memory.store import ScratchpadManager
+
+
+def _cfg(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_build_memory_pack_empty(tmp_path):
+    orchestrator = MemoryOrchestrator(_cfg(tmp_path))
+    pack = orchestrator.build_memory_pack(1)
+    assert pack["stats"]["total"] == 0
+    assert pack["semantic_memory"] == []
+    assert pack["long_term_facts"] == pack["semantic_memory"]
+    assert len(pack["long_term_facts"]) == pack["stats"]["injected"]
+    assert "working_memory" in pack
+    assert "episodic_memory" in pack
+    assert "semantic_memory" in pack
+
+
+def test_build_memory_pack_filter_and_budget(tmp_path):
+    cfg = _cfg(tmp_path)
+    cfg.memory_orchestrator_max_items = 1
+    outline_dir = cfg.project_root / "大纲"
+    outline_dir.mkdir(parents=True, exist_ok=True)
+    (outline_dir / "第1卷 详细大纲.md").write_text("### 第10章:萧炎突破\n", encoding="utf-8")
+
+    store = ScratchpadManager(cfg)
+    store.upsert_item(
+        MemoryItem(
+            id="m1",
+            layer="semantic",
+            category="character_state",
+            subject="萧炎",
+            field="realm",
+            value="斗师",
+            source_chapter=9,
+        )
+    )
+    store.upsert_item(
+        MemoryItem(
+            id="m2",
+            layer="semantic",
+            category="story_fact",
+            subject="chapter_hook",
+            field="9",
+            value="神秘强者出现",
+            source_chapter=9,
+        )
+    )
+
+    orchestrator = MemoryOrchestrator(cfg)
+    pack = orchestrator.build_memory_pack(10)
+    assert pack["stats"]["total"] >= 2
+    assert len(pack["long_term_facts"]) == 1
+    assert pack["stats"]["semantic_total"] >= 1
+    assert pack["long_term_facts"] == pack["semantic_memory"]

+ 43 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_schema.py

@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.memory.schema import (
+    BUCKET_TO_CATEGORY,
+    CATEGORY_KEY_RULES,
+    CATEGORY_TO_BUCKET,
+    MemoryItem,
+    ScratchpadData,
+)
+
+
+def test_memory_item_roundtrip_and_payload():
+    item = MemoryItem(
+        id="m1",
+        layer="semantic",
+        category="character_state",
+        subject="xiaoyan",
+        field="realm",
+        value="筑基三层",
+        payload={"old_value": "筑基二层"},
+        source_chapter=12,
+        evidence=["state_change:xiaoyan:realm:12"],
+    )
+    raw = item.to_dict()
+    rebuilt = MemoryItem.from_dict(raw)
+    assert rebuilt.value == "筑基三层"
+    assert rebuilt.payload.get("old_value") == "筑基二层"
+    assert rebuilt.status == "active"
+
+
+def test_scratchpad_data_default_and_count():
+    data = ScratchpadData.empty()
+    assert data.count_items() == 0
+    raw = data.to_dict()
+    assert raw["meta"]["version"] == 1
+    assert raw["meta"]["total_items"] == 0
+
+
+def test_category_bucket_mapping_complete():
+    assert set(CATEGORY_TO_BUCKET.values()) == set(BUCKET_TO_CATEGORY.keys())
+    assert set(CATEGORY_TO_BUCKET.keys()) == set(CATEGORY_KEY_RULES.keys())
+

+ 36 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_schema_compat.py

@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.schemas import normalize_data_agent_output, validate_data_agent_output
+
+
+def test_data_agent_output_compatible_without_memory_facts():
+    payload = {
+        "entities_appeared": [],
+        "entities_new": [],
+        "state_changes": [],
+        "relationships_new": [],
+    }
+    normalized = normalize_data_agent_output(payload)
+    validated = validate_data_agent_output(normalized)
+    assert validated.memory_facts is not None
+
+
+def test_data_agent_output_accepts_memory_facts():
+    payload = {
+        "entities_appeared": [],
+        "entities_new": [],
+        "state_changes": [],
+        "relationships_new": [],
+        "memory_facts": {
+            "timeline_events": [{"event": "萧炎离开天云宗", "chapter": 12}],
+            "world_rules": [{"rule": "修炼体系九境", "scope": "global"}],
+            "open_loops": [{"content": "三年之约", "status": "active"}],
+            "reader_promises": [{"content": "纳兰嫣然会出场", "type": "encounter"}],
+        },
+    }
+    normalized = normalize_data_agent_output(payload)
+    validated = validate_data_agent_output(normalized)
+    assert len(validated.memory_facts.timeline_events) == 1
+    assert len(validated.memory_facts.world_rules) == 1
+

+ 206 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_store.py

@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.config import DataModulesConfig
+from data_modules.memory.schema import MemoryItem
+from data_modules.memory.store import ScratchpadManager
+
+
+def _cfg(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_load_empty_file(tmp_path):
+    manager = ScratchpadManager(_cfg(tmp_path))
+    data = manager.load()
+    assert data.count_items() == 0
+
+
+def test_upsert_character_state_marks_old_outdated(tmp_path):
+    manager = ScratchpadManager(_cfg(tmp_path))
+    item1 = MemoryItem(
+        id="a1",
+        layer="semantic",
+        category="character_state",
+        subject="xiaoyan",
+        field="realm",
+        value="斗者",
+        source_chapter=1,
+    )
+    item2 = MemoryItem(
+        id="a2",
+        layer="semantic",
+        category="character_state",
+        subject="xiaoyan",
+        field="realm",
+        value="斗师",
+        source_chapter=2,
+    )
+    manager.upsert_item(item1)
+    manager.upsert_item(item2)
+    active = manager.query(category="character_state", subject="xiaoyan", status="active")
+    outdated = manager.query(category="character_state", subject="xiaoyan", status="outdated")
+    assert len(active) == 1
+    assert active[0].value == "斗师"
+    assert len(outdated) == 1
+
+
+def test_upsert_world_rule_with_subject_field_key(tmp_path):
+    manager = ScratchpadManager(_cfg(tmp_path))
+    manager.upsert_item(
+        MemoryItem(
+            id="w1",
+            layer="semantic",
+            category="world_rule",
+            subject="修炼体系",
+            field="境界划分",
+            value="九境",
+            source_chapter=1,
+        )
+    )
+    manager.upsert_item(
+        MemoryItem(
+            id="w2",
+            layer="semantic",
+            category="world_rule",
+            subject="修炼体系",
+            field="突破条件",
+            value="需心境",
+            source_chapter=2,
+        )
+    )
+    rows = manager.query(category="world_rule", status="active")
+    assert len(rows) == 2
+
+
+def test_timeline_same_subject_different_chapter_should_append(tmp_path):
+    manager = ScratchpadManager(_cfg(tmp_path))
+    manager.upsert_item(
+        MemoryItem(
+            id="t1",
+            layer="semantic",
+            category="timeline",
+            subject="离开宗门",
+            field="event",
+            value="离开宗门",
+            source_chapter=10,
+        )
+    )
+    manager.upsert_item(
+        MemoryItem(
+            id="t2",
+            layer="semantic",
+            category="timeline",
+            subject="离开宗门",
+            field="event",
+            value="离开宗门",
+            source_chapter=20,
+        )
+    )
+    rows = manager.query(category="timeline", status="active")
+    assert len(rows) == 2
+
+
+def test_mark_status_and_stats(tmp_path):
+    manager = ScratchpadManager(_cfg(tmp_path))
+    manager.upsert_item(
+        MemoryItem(
+            id="p1",
+            layer="semantic",
+            category="reader_promise",
+            subject="纳兰嫣然出场",
+            field="promise",
+            value="纳兰嫣然会在宗门大比前出场",
+            source_chapter=8,
+        )
+    )
+    assert manager.mark_status("p1", "tentative") is True
+    rows = manager.query(category="reader_promise", status="tentative")
+    assert len(rows) == 1
+    stats = manager.stats()
+    assert stats["total"] >= 1
+
+
+def test_compactor_enforces_global_limit_and_dedupes_timeline_summary(tmp_path):
+    cfg = _cfg(tmp_path)
+    cfg.memory_compactor_enabled = True
+    cfg.memory_compactor_threshold = 3
+    manager = ScratchpadManager(cfg)
+
+    manager.upsert_item(
+        MemoryItem(
+            id="t-old-1",
+            layer="semantic",
+            category="timeline",
+            subject="事件一",
+            field="event",
+            value="事件一",
+            source_chapter=1,
+        )
+    )
+    manager.upsert_item(
+        MemoryItem(
+            id="t-old-2",
+            layer="semantic",
+            category="timeline",
+            subject="事件二",
+            field="event",
+            value="事件二",
+            source_chapter=2,
+        )
+    )
+    manager.upsert_item(
+        MemoryItem(
+            id="t-fresh",
+            layer="semantic",
+            category="timeline",
+            subject="事件三",
+            field="event",
+            value="事件三",
+            source_chapter=60,
+        )
+    )
+    manager.upsert_item(
+        MemoryItem(
+            id="w1",
+            layer="semantic",
+            category="world_rule",
+            subject="修炼体系",
+            field="境界划分",
+            value="九境",
+            source_chapter=60,
+        )
+    )
+
+    dump1 = manager.dump()
+    total1 = sum(len(v) for k, v in dump1.items() if isinstance(v, list))
+    assert total1 <= 3
+    summary_count1 = sum(
+        1
+        for row in dump1.get("story_facts", [])
+        if row.get("subject") == "timeline_summary"
+    )
+    assert summary_count1 <= 1
+
+    manager.upsert_item(
+        MemoryItem(
+            id="w2",
+            layer="semantic",
+            category="world_rule",
+            subject="地理",
+            field="区域",
+            value="中州",
+            source_chapter=61,
+        )
+    )
+    dump2 = manager.dump()
+    total2 = sum(len(v) for k, v in dump2.items() if isinstance(v, list))
+    assert total2 <= 3
+    summary_count2 = sum(
+        1
+        for row in dump2.get("story_facts", [])
+        if row.get("subject") == "timeline_summary"
+    )
+    assert summary_count2 <= 1

+ 51 - 0
webnovel-writer/scripts/data_modules/tests/test_memory_writer.py

@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from data_modules.config import DataModulesConfig
+from data_modules.memory.store import ScratchpadManager
+from data_modules.memory.writer import MemoryWriter
+
+
+def _cfg(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    return cfg
+
+
+def test_writer_stage2_mapping(tmp_path):
+    cfg = _cfg(tmp_path)
+    writer = MemoryWriter(cfg)
+    result = {
+        "entities_new": [{"suggested_id": "yaolao", "name": "药老", "type": "角色", "tier": "重要"}],
+        "state_changes": [{"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师"}],
+        "relationships_new": [{"from": "xiaoyan", "to": "yaolao", "type": "师徒", "description": "收徒"}],
+        "chapter_meta": {"hook": {"content": "三年之约将至", "type": "悬念钩", "strength": "strong"}},
+    }
+    summary = writer.update_from_chapter_result(12, result)
+    assert summary["items_added"] >= 4
+    store = ScratchpadManager(cfg)
+    chars = store.query(category="character_state", status="active")
+    assert any(x.subject == "xiaoyan" and x.field == "realm" for x in chars)
+    rels = store.query(category="relationship", status="active")
+    assert any(x.subject == "xiaoyan" and x.field == "yaolao" for x in rels)
+
+
+def test_writer_stage4_memory_facts_mapping(tmp_path):
+    cfg = _cfg(tmp_path)
+    writer = MemoryWriter(cfg)
+    result = {
+        "memory_facts": {
+            "timeline_events": [{"event": "萧炎离开天云宗", "chapter": 100, "time_hint": "三年之约前夕"}],
+            "world_rules": [{"rule": "修炼体系九境", "scope": "global", "domain": "修炼体系", "field": "境界划分"}],
+            "open_loops": [{"content": "三年之约", "status": "active", "urgency": 80}],
+            "reader_promises": [{"content": "纳兰嫣然会出场", "type": "encounter", "target": "纳兰嫣然"}],
+        }
+    }
+    summary = writer.update_from_chapter_result(100, result)
+    assert summary["items_added"] >= 4
+    store = ScratchpadManager(cfg)
+    assert store.query(category="timeline", status="active")
+    assert store.query(category="world_rule", status="active")
+    assert store.query(category="open_loop", status="active")
+    assert store.query(category="reader_promise", status="active")
+

+ 5 - 0
webnovel-writer/scripts/data_modules/tests/test_migrate_state_to_sqlite.py

@@ -22,6 +22,8 @@ from data_modules.index_manager import IndexManager
 def temp_project(tmp_path):
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()
+    if not cfg.state_file.exists():
+        cfg.state_file.write_text("{}", encoding="utf-8")
     return cfg
 
 
@@ -125,6 +127,9 @@ def test_migrate_state_verbose_and_dry_run(temp_project, capsys):
 
 def test_migrate_state_cli_main(tmp_path, monkeypatch, capsys):
     project_root = tmp_path
+    webnovel_dir = project_root / ".webnovel"
+    webnovel_dir.mkdir(parents=True, exist_ok=True)
+    (webnovel_dir / "state.json").write_text("{}", encoding="utf-8")
     args = [
         "migrate_state_to_sqlite",
         "--project-root",

+ 2 - 0
webnovel-writer/scripts/data_modules/tests/test_rag_adapter.py

@@ -61,6 +61,8 @@ class StubClientRerankFailure(StubClient):
 def temp_project(tmp_path, monkeypatch):
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()
+    if not cfg.state_file.exists():
+        cfg.state_file.write_text("{}", encoding="utf-8")
     monkeypatch.setattr(rag_module, "get_client", lambda config: StubClient())
     return cfg
 

+ 2 - 0
webnovel-writer/scripts/data_modules/tests/test_relationship_graph.py

@@ -23,6 +23,8 @@ from data_modules.index_manager import (
 def temp_project(tmp_path):
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()
+    if not cfg.state_file.exists():
+        cfg.state_file.write_text("{}", encoding="utf-8")
     return cfg
 
 

+ 2 - 0
webnovel-writer/scripts/data_modules/tests/test_sql_state_manager.py

@@ -19,6 +19,8 @@ def temp_project(tmp_path):
     from data_modules.config import DataModulesConfig
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()
+    if not cfg.state_file.exists():
+        cfg.state_file.write_text("{}", encoding="utf-8")
     return cfg
 
 

+ 31 - 0
webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py

@@ -201,6 +201,12 @@ def test_process_chapter_result_and_sqlite_sync(temp_project):
             },
         ],
         "chapter_meta": {"hook": "test", "end": "ok"},
+        "memory_facts": {
+            "timeline_events": [{"event": "萧炎离开天云宗", "chapter": 12, "time_hint": "夜晚"}],
+            "world_rules": [{"rule": "修炼体系九境", "scope": "global", "domain": "修炼体系", "field": "境界划分"}],
+            "open_loops": [{"content": "三年之约", "status": "active", "urgency": 80}],
+            "reader_promises": [{"content": "纳兰嫣然会出场", "type": "encounter"}],
+        },
     }
     warnings = manager.process_chapter_result(12, result)
     assert any("需人工确认" in w for w in warnings)
@@ -217,6 +223,7 @@ def test_process_chapter_result_and_sqlite_sync(temp_project):
     by_tier = manager.get_entities_by_tier("核心")
     assert "xiaoyan" in by_type
     assert "xiaoyan" in by_tier
+    assert temp_project.scratchpad_file.exists()
 
 
 def test_export_context_and_protagonist_alias(temp_project):
@@ -491,6 +498,10 @@ def test_sync_protagonist_from_string_and_empty_updates(temp_project):
 
 
 def test_state_manager_cli_commands(temp_project, monkeypatch, capsys):
+    # CLI 的 resolve_project_root 需要 state.json 存在
+    if not temp_project.state_file.exists():
+        temp_project.state_file.write_text("{}", encoding="utf-8")
+
     idx = IndexManager(temp_project)
     idx.upsert_entity(
         EntityMeta(
@@ -546,6 +557,26 @@ def test_state_manager_cli_commands(temp_project, monkeypatch, capsys):
     assert out["status"] == "success"
 
 
+def test_state_manager_cli_rejects_invalid_project_root(monkeypatch, tmp_path, capsys):
+    invalid_root = tmp_path / "not-a-project"
+    invalid_root.mkdir(parents=True, exist_ok=True)
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        ["state_manager", "--project-root", str(invalid_root), "get-progress"],
+    )
+
+    from data_modules import state_manager as sm
+
+    with pytest.raises(SystemExit) as exc:
+        sm.main()
+    out = json.loads(capsys.readouterr().out)
+    assert int(exc.value.code or 0) == 1
+    assert out["status"] == "error"
+    assert out["error"]["code"] == "INVALID_PROJECT_ROOT"
+
+
 def test_save_state_timeout(monkeypatch, temp_project):
     import filelock
     from data_modules import state_manager as sm

+ 0 - 1
webnovel-writer/scripts/data_modules/tests/test_state_validator.py

@@ -104,4 +104,3 @@ def test_normalize_state_runtime_sections():
     chapter_meta = normalize_chapter_meta(normalized["chapter_meta"])
     assert "1" in chapter_meta
     assert chapter_meta["1"]["coolpoint_patterns"] == ["打脸", "翻车"]
-

+ 2 - 0
webnovel-writer/scripts/data_modules/tests/test_style_sampler_cli.py

@@ -18,6 +18,8 @@ from data_modules.config import DataModulesConfig
 def temp_project(tmp_path):
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()
+    if not cfg.state_file.exists():
+        cfg.state_file.write_text("{}", encoding="utf-8")
     return cfg
 
 

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

@@ -1,6 +1,7 @@
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 
+import json
 import sys
 from pathlib import Path
 
@@ -166,3 +167,225 @@ def test_quality_trend_report_writes_to_book_root_when_input_is_workspace_root(t
     assert output_path.is_file()
     assert (book_root / ".webnovel" / "index.db").is_file()
     assert not (workspace_root / ".webnovel" / "index.db").exists()
+
+
+
+def test_index_aggregate_review_results_forwards_with_resolved_project_root(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+
+    book_root = (tmp_path / "book").resolve()
+    called = {}
+
+    def _fake_resolve(explicit_project_root=None):
+        return book_root
+
+    def _fake_run_data_module(module_name, argv):
+        called["module_name"] = module_name
+        called["argv"] = list(argv)
+        return 0
+
+    monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
+    monkeypatch.setattr(module, "_run_data_module", _fake_run_data_module)
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "webnovel",
+            "--project-root",
+            str(tmp_path),
+            "index",
+            "aggregate-review-results",
+            "--chapter",
+            "12",
+            "--data",
+            '{"continuity-checker":{"overall_score":80}}',
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    assert int(exc.value.code or 0) == 0
+    assert called["module_name"] == "index_manager"
+    assert called["argv"] == [
+        "--project-root",
+        str(book_root),
+        "aggregate-review-results",
+        "--chapter",
+        "12",
+        "--data",
+        '{"continuity-checker":{"overall_score":80}}',
+    ]
+
+
+
+
+
+def test_review_pipeline_builds_artifacts(tmp_path):
+    _ensure_scripts_on_path()
+    import review_pipeline as review_pipeline_module
+
+    project_root = (tmp_path / "book").resolve()
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+
+    review_results_path = tmp_path / "review_results.json"
+    review_results_path.write_text(
+        json.dumps(
+            {
+                "continuity-checker": {
+                    "agent": "continuity-checker",
+                    "chapter": 20,
+                    "overall_score": 76,
+                    "pass": True,
+                    "issues": [
+                        {
+                            "id": "TIME_001",
+                            "type": "TIMELINE_ISSUE",
+                            "severity": "high",
+                            "location": "第2段",
+                            "description": "时间线回跳",
+                            "suggestion": "补时间锚点",
+                        }
+                    ],
+                    "metrics": {},
+                    "summary": "存在时间线问题",
+                },
+                "consistency-checker": {
+                    "agent": "consistency-checker",
+                    "chapter": 20,
+                    "overall_score": 84,
+                    "pass": True,
+                    "issues": [],
+                    "metrics": {},
+                    "summary": "设定稳定",
+                },
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    payload = review_pipeline_module.build_review_artifacts(
+        project_root=project_root,
+        chapter=20,
+        review_results_path=review_results_path,
+        report_file="审查报告/第20章.md",
+    )
+
+    assert payload["aggregated"]["overall_score"] == 80.0
+    assert payload["aggregated"]["timeline_gate"]["blocked"] is True
+    assert payload["review_metrics"]["report_file"] == "审查报告/第20章.md"
+    assert "timeline_gate:block=True;count=1" in payload["review_metrics"]["notes"]
+
+
+def test_review_pipeline_forwards_with_resolved_project_root(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+
+    book_root = (tmp_path / "book").resolve()
+    review_results = (tmp_path / "review_results.json").resolve()
+    called = {}
+
+    def _fake_resolve(explicit_project_root=None):
+        return book_root
+
+    def _fake_run_script(script_name, argv):
+        called["script_name"] = script_name
+        called["argv"] = list(argv)
+        return 0
+
+    monkeypatch.setattr(module, "_resolve_root", _fake_resolve)
+    monkeypatch.setattr(module, "_run_script", _fake_run_script)
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "webnovel",
+            "--project-root",
+            str(tmp_path),
+            "review-pipeline",
+            "--chapter",
+            "18",
+            "--review-results",
+            str(review_results),
+            "--aggregated-out",
+            str(tmp_path / "aggregated.json"),
+            "--review-metrics-out",
+            str(tmp_path / "review_metrics.json"),
+            "--report-file",
+            "审查报告/第18章.md",
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    assert int(exc.value.code or 0) == 0
+    assert called["script_name"] == "review_pipeline.py"
+    assert called["argv"] == [
+        "--project-root",
+        str(book_root),
+        "--chapter",
+        "18",
+        "--review-results",
+        str(review_results),
+        "--aggregated-out",
+        str(tmp_path / "aggregated.json"),
+        "--review-metrics-out",
+        str(tmp_path / "review_metrics.json"),
+        "--report-file",
+        "审查报告/第18章.md",
+    ]
+
+
+def test_review_pipeline_main_creates_output_directories(tmp_path):
+    _ensure_scripts_on_path()
+    import review_pipeline as review_pipeline_module
+
+    project_root = (tmp_path / "book").resolve()
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+
+    review_results_path = tmp_path / "review_results.json"
+    review_results_path.write_text(
+        json.dumps(
+            {
+                "consistency-checker": {
+                    "agent": "consistency-checker",
+                    "chapter": 9,
+                    "overall_score": 88,
+                    "pass": True,
+                    "issues": [],
+                    "metrics": {},
+                    "summary": "稳定",
+                }
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    aggregated_out = project_root / ".webnovel" / "tmp" / "review" / "aggregated.json"
+    metrics_out = project_root / ".webnovel" / "tmp" / "review" / "metrics.json"
+
+    old_argv = sys.argv
+    sys.argv = [
+        "review_pipeline",
+        "--project-root",
+        str(project_root),
+        "--chapter",
+        "9",
+        "--review-results",
+        str(review_results_path),
+        "--aggregated-out",
+        str(aggregated_out),
+        "--review-metrics-out",
+        str(metrics_out),
+    ]
+    try:
+        review_pipeline_module.main()
+    finally:
+        sys.argv = old_argv
+
+    assert aggregated_out.is_file()
+    assert metrics_out.is_file()

+ 21 - 0
webnovel-writer/scripts/data_modules/tests/test_workflow_manager.py

@@ -202,3 +202,24 @@ def test_cleanup_artifacts_confirm_deletes_with_backup(tmp_path, monkeypatch):
     backup_dir = tmp_path / ".webnovel" / "recovery_backups"
     backups = list(backup_dir.glob("ch0008-*"))
     assert backups
+
+
+def test_workflow_step_zero_point_five_is_registered(tmp_path, monkeypatch):
+    module = _load_module()
+    monkeypatch.setattr(module, "find_project_root", lambda: tmp_path)
+
+    webnovel_dir = tmp_path / ".webnovel"
+    webnovel_dir.mkdir(parents=True, exist_ok=True)
+
+    assert module.expected_step_owner("webnovel-write", "Step 0.5") == "webnovel-write-skill"
+    assert module.get_pending_steps("webnovel-write")[0] == "Step 0.5"
+
+    module.start_task("webnovel-write", {"chapter_num": 15})
+    module.start_step("Step 0.5", "节点预检")
+    module.complete_step("Step 0.5")
+    module.start_step("Step 1", "Context")
+
+    trace_path = module.get_call_trace_path()
+    rows = [json.loads(line) for line in trace_path.read_text(encoding="utf-8").splitlines() if line.strip()]
+    violation_events = [row for row in rows if row.get("event") == "step_order_violation"]
+    assert not violation_events

+ 25 - 0
webnovel-writer/scripts/data_modules/webnovel.py

@@ -223,6 +223,9 @@ def main() -> None:
     p_context = sub.add_parser("context", help="转发到 context_manager")
     p_context.add_argument("args", nargs=argparse.REMAINDER)
 
+    p_memory = sub.add_parser("memory", help="转发到 memory.store")
+    p_memory.add_argument("args", nargs=argparse.REMAINDER)
+
     p_migrate = sub.add_parser("migrate", help="转发到 migrate_state_to_sqlite")
     p_migrate.add_argument("args", nargs=argparse.REMAINDER)
 
@@ -249,6 +252,13 @@ def main() -> None:
     p_extract_context.add_argument("--chapter", type=int, required=True, help="目标章节号")
     p_extract_context.add_argument("--format", choices=["text", "json"], default="text", help="输出格式")
 
+    p_review_pipeline = sub.add_parser("review-pipeline", help="转发到 review_pipeline.py")
+    p_review_pipeline.add_argument("--chapter", type=int, required=True, help="目标章节号")
+    p_review_pipeline.add_argument("--review-results", required=True, help="checker 原始结果 JSON 文件")
+    p_review_pipeline.add_argument("--aggregated-out", help="聚合结果输出文件")
+    p_review_pipeline.add_argument("--review-metrics-out", help="review_metrics 输出文件")
+    p_review_pipeline.add_argument("--report-file", default="", help="审查报告路径")
+
     # 兼容:允许 `--project-root` 出现在任意位置(减少 agents/skills 拼命令的出错率)
     from .cli_args import normalize_global_project_root
 
@@ -287,6 +297,8 @@ def main() -> None:
         raise SystemExit(_run_data_module("entity_linker", [*forward_args, *rest]))
     if tool == "context":
         raise SystemExit(_run_data_module("context_manager", [*forward_args, *rest]))
+    if tool == "memory":
+        raise SystemExit(_run_data_module("memory.store", [*forward_args, *rest]))
     if tool == "migrate":
         raise SystemExit(_run_data_module("migrate_state_to_sqlite", [*forward_args, *rest]))
 
@@ -303,6 +315,19 @@ def main() -> None:
     if tool == "extract-context":
         return_args = [*forward_args, "--chapter", str(args.chapter), "--format", str(args.format)]
         raise SystemExit(_run_script("extract_chapter_context.py", return_args))
+    if tool == "review-pipeline":
+        return_args = [
+            *forward_args,
+            "--chapter", str(args.chapter),
+            "--review-results", str(args.review_results),
+        ]
+        if args.aggregated_out:
+            return_args.extend(["--aggregated-out", str(args.aggregated_out)])
+        if args.review_metrics_out:
+            return_args.extend(["--review-metrics-out", str(args.review_metrics_out)])
+        if args.report_file:
+            return_args.extend(["--report-file", str(args.report_file)])
+        raise SystemExit(_run_script("review_pipeline.py", return_args))
 
     raise SystemExit(2)
 

+ 56 - 1
webnovel-writer/scripts/extract_chapter_context.py

@@ -20,7 +20,7 @@ import sys
 from pathlib import Path
 from typing import Any, Dict, List
 
-from chapter_outline_loader import load_chapter_outline
+from chapter_outline_loader import load_chapter_outline, load_chapter_plot_structure
 
 from runtime_compat import enable_windows_utf8_stdio
 
@@ -314,6 +314,8 @@ def _load_contract_context(project_root: Path, chapter_num: int) -> Dict[str, An
         "reader_signal": (sections.get("reader_signal") or {}).get("content", {}),
         "genre_profile": (sections.get("genre_profile") 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", {}),
     }
 
 
@@ -328,6 +330,7 @@ def build_chapter_context_payload(project_root: Path, chapter_num: int) -> Dict[
 
     state_summary = extract_state_summary(project_root)
     contract_context = _load_contract_context(project_root, chapter_num)
+    plot_structure = contract_context.get("plot_structure") or load_chapter_plot_structure(project_root, chapter_num)
     rag_assist = _load_rag_assist(project_root, chapter_num, outline)
 
     return {
@@ -340,6 +343,8 @@ def build_chapter_context_payload(project_root: Path, chapter_num: int) -> Dict[
         "reader_signal": contract_context.get("reader_signal", {}),
         "genre_profile": contract_context.get("genre_profile", {}),
         "writing_guidance": contract_context.get("writing_guidance", {}),
+        "plot_structure": plot_structure,
+        "long_term_memory": contract_context.get("long_term_memory", {}),
         "rag_assist": rag_assist,
     }
 
@@ -380,6 +385,26 @@ def _render_text(payload: Dict[str, Any]) -> str:
             lines.append(f"- 上下文阶段权重: {stage}")
             lines.append("")
 
+    plot_structure = payload.get("plot_structure") or {}
+    if plot_structure:
+        lines.append("## 情节结构")
+        lines.append("")
+        cbn = str(plot_structure.get("cbn") or "").strip()
+        if cbn:
+            lines.append(f"- CBN: {cbn}")
+        for idx, item in enumerate(plot_structure.get("cpns") or [], start=1):
+            lines.append(f"- CPN{idx}: {item}")
+        cen = str(plot_structure.get("cen") or "").strip()
+        if cen:
+            lines.append(f"- CEN: {cen}")
+        mandatory_nodes = plot_structure.get("mandatory_nodes") or []
+        if mandatory_nodes:
+            lines.append("- 必须覆盖节点: " + " | ".join(str(x) for x in mandatory_nodes))
+        prohibitions = plot_structure.get("prohibitions") or []
+        if prohibitions:
+            lines.append("- 本章禁区: " + " | ".join(str(x) for x in prohibitions))
+        lines.append("")
+
     writing_guidance = payload.get("writing_guidance") or {}
     guidance_items = writing_guidance.get("guidance_items") or []
     checklist = writing_guidance.get("checklist") or []
@@ -482,6 +507,36 @@ def _render_text(payload: Dict[str, Any]) -> str:
             lines.append(f"- {row}")
         lines.append("")
 
+    long_term_memory = payload.get("long_term_memory") or {}
+    if long_term_memory:
+        lines.append("## 长期记忆")
+        lines.append("")
+        stats = long_term_memory.get("stats") or {}
+        if stats:
+            lines.append(
+                f"- 注入条目: {stats.get('injected', 0)} / 总条目: {stats.get('total', 0)}"
+            )
+        active_constraints = long_term_memory.get("active_constraints") or []
+        if active_constraints:
+            lines.append("- 活跃约束:")
+            for row in active_constraints[:5]:
+                value = str(row.get("value", "") or "").strip()
+                subject = str(row.get("subject", "") or "").strip()
+                if subject:
+                    lines.append(f"  - [{subject}] {value}")
+                else:
+                    lines.append(f"  - {value}")
+        facts = long_term_memory.get("long_term_facts") or []
+        if facts:
+            lines.append("- 关键长期事实:")
+            for row in facts[:5]:
+                category = str(row.get("category", "") or "").strip()
+                subject = str(row.get("subject", "") or "").strip()
+                field = str(row.get("field", "") or "").strip()
+                value = str(row.get("value", "") or "").strip()
+                lines.append(f"  - ({category}) {subject}.{field} = {value}")
+        lines.append("")
+
     rag_assist = payload.get("rag_assist") or {}
     hits = rag_assist.get("hits") or []
     if rag_assist.get("invoked") and hits:

+ 86 - 0
webnovel-writer/scripts/review_pipeline.py

@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+review_pipeline.py - Step 3 审查结果文件流辅助脚本
+
+职责:
+- 读取 checker 原始结果文件
+- 调用 index_manager 聚合审查结果
+- 物化为 review_metrics 结构
+- 可选写入 review_metrics 到 index.db
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from typing import Any, Dict
+
+from runtime_compat import enable_windows_utf8_stdio
+
+
+def _ensure_scripts_path() -> None:
+    scripts_dir = Path(__file__).resolve().parent
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+
+def _load_json(path: Path) -> Dict[str, Any]:
+    return json.loads(path.read_text(encoding="utf-8"))
+
+
+def _write_json(path: Path, payload: Dict[str, Any]) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+
+def build_review_artifacts(project_root: Path, chapter: int, review_results_path: Path, report_file: str = "") -> Dict[str, Any]:
+    _ensure_scripts_path()
+    from data_modules.config import DataModulesConfig
+    from data_modules.index_manager import _aggregate_checker_results, ReviewAggregateResult
+
+    _ = DataModulesConfig.from_project_root(project_root)
+    raw_data = _load_json(review_results_path)
+    aggregated = ReviewAggregateResult(**_aggregate_checker_results(chapter, raw_data))
+    review_metrics = aggregated.to_review_metrics(report_file=report_file)
+    return {
+        "chapter": chapter,
+        "review_results": raw_data,
+        "aggregated": aggregated.__dict__,
+        "review_metrics": review_metrics.__dict__,
+    }
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="构建 Step 3 审查中间产物")
+    parser.add_argument("--project-root", required=True, help="项目根目录")
+    parser.add_argument("--chapter", type=int, required=True, help="章节号")
+    parser.add_argument("--review-results", required=True, help="checker 原始结果 JSON 文件")
+    parser.add_argument("--aggregated-out", help="聚合结果输出文件")
+    parser.add_argument("--review-metrics-out", help="review_metrics 输出文件")
+    parser.add_argument("--report-file", default="", help="审查报告路径")
+    args = parser.parse_args()
+
+    project_root = Path(args.project_root).resolve()
+    review_results_path = Path(args.review_results).resolve()
+    payload = build_review_artifacts(
+        project_root=project_root,
+        chapter=args.chapter,
+        review_results_path=review_results_path,
+        report_file=args.report_file,
+    )
+
+    if args.aggregated_out:
+        _write_json(Path(args.aggregated_out), payload["aggregated"])
+
+    if args.review_metrics_out:
+        _write_json(Path(args.review_metrics_out), payload["review_metrics"])
+
+    print(json.dumps(payload, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+    if sys.platform == "win32":
+        enable_windows_utf8_stdio()
+    main()

+ 35 - 0
webnovel-writer/scripts/status_reporter.py

@@ -848,6 +848,10 @@ class StatusReporter:
         if focus in ["all", "basic"]:
             report_lines.extend(self._generate_basic_stats())
 
+        # 记忆统计(长期记忆)
+        if focus in ["all", "basic"]:
+            report_lines.extend(self._generate_memory_section())
+
         # 角色活跃度
         if focus in ["all", "characters"]:
             report_lines.extend(self._generate_character_section())
@@ -874,6 +878,37 @@ class StatusReporter:
 
         return "\n".join(report_lines)
 
+    def _generate_memory_section(self) -> List[str]:
+        """生成长期记忆统计章节(best-effort)。"""
+        try:
+            from data_modules.memory.store import ScratchpadManager
+        except Exception:
+            try:
+                from scripts.data_modules.memory.store import ScratchpadManager
+            except Exception:
+                return []
+
+        try:
+            manager = ScratchpadManager(self.config)
+            stats = manager.stats()
+            conflicts = manager.conflicts()
+        except Exception:
+            return []
+
+        return [
+            "## 🧠 长期记忆统计",
+            "",
+            f"- **总条目**: {stats.get('total', 0)}",
+            f"- **Active**: {stats.get('active', 0)}",
+            f"- **Outdated**: {stats.get('outdated', 0)}",
+            f"- **Contradicted**: {stats.get('contradicted', 0)}",
+            f"- **Tentative**: {stats.get('tentative', 0)}",
+            f"- **冲突键数量**: {len(conflicts)}",
+            "",
+            "---",
+            "",
+        ]
+
     def _generate_basic_stats(self) -> List[str]:
         """生成基本统计"""
         if not self.state:

+ 13 - 2
webnovel-writer/scripts/workflow_manager.py

@@ -42,6 +42,16 @@ STEP_STATUS_RUNNING = "running"
 STEP_STATUS_COMPLETED = "completed"
 STEP_STATUS_FAILED = "failed"
 
+WRITE_WORKFLOW_STEPS = [
+    "Step 0.5",
+    "Step 1",
+    "Step 2A",
+    "Step 2B",
+    "Step 3",
+    "Step 4",
+    "Step 5",
+    "Step 6",
+]
 
 def now_iso() -> str:
     return datetime.now().isoformat()
@@ -110,6 +120,7 @@ def expected_step_owner(command: str, step_id: str) -> str:
     """
     if command == "webnovel-write":
         mapping = {
+            "Step 0.5": "webnovel-write-skill",
             "Step 1": "context-agent",
             "Step 1.5": "webnovel-write-skill",
             "Step 2A": "writer-draft",
@@ -423,7 +434,7 @@ def analyze_recovery_options(interrupt_info):
 
     step_id = current_step["id"]
 
-    if step_id in {"Step 1", "Step 1.5"}:
+    if step_id in {"Step 0.5", "Step 1", "Step 1.5"}:
         return [
             {
                 "option": "A",
@@ -715,7 +726,7 @@ def get_pending_steps(command):
     """Get command pending step list."""
     if command == "webnovel-write":
         # v2: Step 1 内置 Contract v2,不再单独记录 Step 1.5,避免产生 step_order_violation 噪声。
-        return ["Step 1", "Step 2A", "Step 2B", "Step 3", "Step 4", "Step 5", "Step 6"]
+        return list(WRITE_WORKFLOW_STEPS)
     if command == "webnovel-review":
         return ["Step 1", "Step 2", "Step 3", "Step 4", "Step 5", "Step 6", "Step 7", "Step 8"]
     return []

+ 21 - 27
webnovel-writer/skills/webnovel-dashboard/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: webnovel-dashboard
-description: 启动可视化小说管理面板(只读 Web Dashboard),实时查看项目状态、实体图谱与章节内容。
+description: 启动只读小说管理面板,查看项目状态、实体图谱与章节内容。
 allowed-tools: Bash Read
 ---
 
@@ -8,18 +8,13 @@ allowed-tools: Bash Read
 
 ## 目标
 
-在本地启动一个 **只读** Web 面板,用于可视化查看当前小说项目的:
-- 创作进度与 Strand 节奏分布
-- 设定词典(角色/地点/势力等实体)
-- 关系图谱
-- 章节与大纲内容浏览
-- 追读力分析数据
+- 在本地启动只读 Web 面板。
+- 实时查看创作进度、设定词典、关系图谱、章节内容与追读力数据。
+- 允许监听 `.webnovel/` 变化,但不得修改项目内容。
 
-面板通过 `watchdog` 监听 `.webnovel/` 目录变更并实时刷新,不对项目做任何修改。
+## 执行流程
 
-## 执行步骤
-
-### Step 0:环境确认
+### Step 1:确认环境与模块目录
 
 ```bash
 export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
@@ -28,46 +23,45 @@ if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/dashboard" ];
   echo "ERROR: 未找到 dashboard 模块: ${CLAUDE_PLUGIN_ROOT}/dashboard" >&2
   exit 1
 fi
+
 export DASHBOARD_DIR="${CLAUDE_PLUGIN_ROOT}/dashboard"
 ```
 
-### Step 1:安装依赖(首次)
+### Step 2:安装依赖并解析项目根目录
 
 ```bash
 python -m pip install -r "${DASHBOARD_DIR}/requirements.txt" --quiet
-```
-
-### Step 2:解析项目根目录并准备 Python 模块路径
-
-```bash
 export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
 echo "项目路径: ${PROJECT_ROOT}"
+```
+
+补充要求:
+- `PROJECT_ROOT` 必须解析成功
+- 若依赖已安装,可重复执行,不视为错误
+
+### Step 3:准备 Python 模块路径并校验前端产物
 
-# 确保 `python -m dashboard.server` 可在任意工作目录下找到插件模块
+```bash
 if [ -n "${PYTHONPATH:-}" ]; then
   export PYTHONPATH="${CLAUDE_PLUGIN_ROOT}:${PYTHONPATH}"
 else
   export PYTHONPATH="${CLAUDE_PLUGIN_ROOT}"
 fi
 
-# 前端 dist 已随插件发布;若缺失说明安装包异常
 if [ ! -f "${DASHBOARD_DIR}/frontend/dist/index.html" ]; then
   echo "ERROR: 缺少前端构建产物 ${DASHBOARD_DIR}/frontend/dist/index.html" >&2
-  echo "请重新安装插件或联系维护者修复发布包。" >&2
   exit 1
 fi
 ```
 
-### Step 3:启动 Dashboard
+### Step 4:启动 Dashboard
 
 ```bash
 python -m dashboard.server --project-root "${PROJECT_ROOT}"
 ```
 
-启动后会自动打开浏览器访问 `http://127.0.0.1:8765`。
-
-如不需要自动打开浏览器,使用:
+如不需要自动打开浏览器:
 
 ```bash
 python -m dashboard.server --project-root "${PROJECT_ROOT}" --no-browser
@@ -75,6 +69,6 @@ python -m dashboard.server --project-root "${PROJECT_ROOT}" --no-browser
 
 ## 注意事项
 
-- Dashboard 为纯只读面板,所有 API 仅 GET,不提供任何修改接口。
-- 文件读取严格限制在 `PROJECT_ROOT` 范围内,防止路径穿越
-- 如需自定义端口,添加 `--port 9000` 参数
+- Dashboard 为纯只读面板,不提供修改接口。
+- 文件读取必须限制在 `PROJECT_ROOT` 范围内
+- 如需自定义端口,使用 `--port 9000`

+ 8 - 8
webnovel-writer/skills/webnovel-init/SKILL.md

@@ -52,7 +52,7 @@ allowed-tools: Read Write Edit Grep Bash Task AskUserQuestion WebSearch WebFetch
   - 触发:所有项目必读。
 - `references/system-data-flow.md`
   - 用途:初始化产物与后续 `/plan`、`/write` 的数据流一致性检查。
-  - 触发:Step 0 预检必读。
+  - 触发:Step 1 预检必读。
 
 ### worldbuilding
 
@@ -123,7 +123,7 @@ allowed-tools: Read Write Edit Grep Bash Task AskUserQuestion WebSearch WebFetch
 
 ## 交互流程(Deep)
 
-### Step 0:预检与上下文加载
+### Step 1:预检与上下文加载
 
 环境设置(bash 命令执行前):
 ```bash
@@ -151,7 +151,7 @@ export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 输出:
 - 进入 Deep 采集前的“已知信息清单”和“待收集清单”。
 
-### Step 1:故事核与商业定位
+### Step 2:故事核与商业定位
 
 收集项(必收):
 - 书名(可先给工作名)
@@ -171,7 +171,7 @@ export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 - 优先让用户自由描述,再二次结构化确认。
 - 若用户卡住,给 2-4 个候选方向供选。
 
-### Step 2:角色骨架与关系冲突
+### Step 3:角色骨架与关系冲突
 
 收集项(必收):
 - 主角姓名
@@ -185,7 +185,7 @@ export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 - 主角原型标签(成长型/复仇型/天才流等)
 - 多主角分工
 
-### Step 3:金手指与兑现机制
+### Step 4:金手指与兑现机制
 
 收集项(必收):
 - 金手指类型(可为“无金手指”)
@@ -200,7 +200,7 @@ export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 - 若为重生:重生时间点、记忆完整度
 - 若为传承/器灵:辅助边界与出手限制
 
-### Step 4:世界观与力量规则
+### Step 5:世界观与力量规则
 
 收集项(必收):
 - 世界规模(单城/多域/大陆/多界)
@@ -213,7 +213,7 @@ export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 - 宗门/组织层级
 - 境界链与小境界
 
-### Step 5:创意约束包(差异化核心)
+### Step 6:创意约束包(差异化核心)
 
 流程:
 1. 基于题材映射加载反套路库(最多 2 个主相关库)。
@@ -234,7 +234,7 @@ export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 备注:
 - 若用户要求“贴近当下市场”,可触发外部检索并标注时间戳。
 
-### Step 6:一致性复述与最终确认
+### Step 7:一致性复述与最终确认
 
 必须输出“初始化摘要草案”并让用户确认:
 - 故事核(题材/一句话故事/核心冲突)

+ 188 - 395
webnovel-writer/skills/webnovel-plan/SKILL.md

@@ -1,479 +1,272 @@
 ---
 name: webnovel-plan
-description: Builds volume and chapter outlines from the total outline, inherits creative constraints, and prepares writing-ready chapter plans. Use when the user asks for outlining or runs /webnovel-plan.
+description: 基于总纲生成卷纲、时间线和章纲,并把新增设定增量写回现有设定集。
 ---
 
 # Outline Planning
 
-Purpose: refine 总纲 into volume + chapter outlines. Do not redesign the global story.
-Setting policy: 先基于 init 产出的总纲+世界观补齐设定集基线;再在卷纲完成后,直接对现有设定集做增量补充。
+## 目标
 
-## Project Root Guard
-- Claude Code 的“工作区根目录”不一定等于“书项目根目录”。常见结构:工作区为 `D:\wk\xiaoshuo`,书项目为 `D:\wk\xiaoshuo\凡人资本论`。
-- 必须先解析 `PROJECT_ROOT` 为真实书项目根(必须包含 `.webnovel/state.json`),后续所有读写路径都以该目录为准。
+- 基于总纲细化卷纲、时间线与章纲,不重做全局故事。
+- 先补齐设定基线,再产出可直接进入写作的章纲。
+- 卷纲完成后,把新增设定增量写回现有设定集。
+- 将详细大纲升级为“结构化详细大纲”,为下游写作提供中层情节结构。
+
+## 执行原则
+
+1. 只做增量补齐,不重写整份总纲或设定集。
+2. 先锁定卷级节奏,再批量拆章。
+3. 时间线是硬约束,所有章纲都必须带时间字段。
+4. 若发现总纲与设定冲突,先阻断,再等用户裁决。
+5. 结构化节点服务于写作执行,不追求语法学上的严格 SVO 抽取。
+
+## 环境准备
 
-环境设置(bash 命令执行前):
 ```bash
 export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
-
-if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-plan" ]; then
-  echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/skills/webnovel-plan" >&2
-  exit 1
-fi
 export SKILL_ROOT="${CLAUDE_PLUGIN_ROOT}/skills/webnovel-plan"
-
-if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
-  echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/scripts" >&2
-  exit 1
-fi
 export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
-
 export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
 ```
 
-## References(按步骤导航)
-
-- Step 3(必读,节拍表模板):[大纲-卷节拍表.md](../../templates/output/大纲-卷节拍表.md)
-- Step 4.5(必读,时间线模板):[大纲-卷时间线.md](../../templates/output/大纲-卷时间线.md)
-- Step 4(必读,题材配置):[genre-profiles.md](../../references/genre-profiles.md)
-- Step 4(必读,Strand 节奏):[strand-weave-pattern.md](../../references/shared/strand-weave-pattern.md)
-- Step 4(可选,爽点结构需要细化):[cool-points-guide.md](../../references/shared/cool-points-guide.md)
-- Step 5/6(可选,冲突强度分层):[conflict-design.md](references/outlining/conflict-design.md)
-- Step 5(可选,需要钩子/节奏细分):[reading-power-taxonomy.md](../../references/reading-power-taxonomy.md)
-- Step 6(可选,章节微结构细化):[chapter-planning.md](references/outlining/chapter-planning.md)
-- Step 4/5(可选,电竞/直播文/克苏鲁):[genre-volume-pacing.md](references/outlining/genre-volume-pacing.md)
-- 归档(不进主流程):`references/outlining/outline-structure.md`、`references/outlining/plot-frameworks.md`
-
-## Reference Loading Levels (strict, lazy)
-
-Use progressive disclosure and load only what current step requires:
-- L0: No references before scope/volume is confirmed.
-- L1: Before each step, load only the "必读" items in **References(按步骤导航)**.
-- L2: Load optional items only when the trigger condition applies.
-
-## Workflow
-1. Load project data.
-2. Build setting baseline from 总纲 + 世界观 (in-place incremental).
-3. Select volume and confirm scope.
-4. Generate volume beat sheet (节拍表).
-4.5. Generate volume timeline (时间线表).
-5. Generate volume skeleton.
-6. Generate chapter outlines in batches.
-7. Enrich existing setting files from volume outline (in-place incremental).
-8. Validate + save + update state.
-
-## 1) Load project data
+## References
+
+- Step 4:`../../templates/output/大纲-卷节拍表.md`
+- Step 5:`../../templates/output/大纲-卷时间线.md`
+- Step 6:`../../references/genre-profiles.md`
+- Step 6:`../../references/shared/strand-weave-pattern.md`
+- Step 6:`../../references/shared/cool-points-guide.md`(按需)
+- Step 6/7:`references/outlining/conflict-design.md`(按需)
+- Step 7:`../../references/reading-power-taxonomy.md`(按需)
+- Step 7:`references/outlining/chapter-planning.md`(按需)
+- Step 6/7:`references/outlining/genre-volume-pacing.md`(特定题材按需)
+
+## 执行流程
+
+### Step 1:加载项目数据并确认前置条件
+
 ```bash
 cat "$PROJECT_ROOT/.webnovel/state.json"
 cat "$PROJECT_ROOT/大纲/总纲.md"
 ```
 
-Optional (only if they exist):
-- `设定集/主角组.md`
-- `设定集/女主卡.md`
-- `设定集/反派设计.md`
+按需读取:
 - `设定集/世界观.md`
 - `设定集/力量体系.md`
 - `设定集/主角卡.md`
-- `.webnovel/idea_bank.json` (inherit constraints)
+- `设定集/反派设计.md`
+- `.webnovel/idea_bank.json`
 
-If 总纲.md lacks volume ranges / core conflict / climax, ask the user to fill those before proceeding.
+阻断条件:
+- 总纲缺少卷名、章节范围、核心冲突或卷末高潮
 
-## 2) Build setting baseline from 总纲 + 世界观
-目标:在不推翻现有内容的前提下,让设定集从“骨架模板”进入“可规划可写作”的基线状态。
+### Step 2:补齐设定基线
 
-输入来源:
-- `大纲/总纲.md`
-- `设定集/世界观.md`
-- `设定集/力量体系.md`
-- `设定集/主角卡.md`
-- `设定集/反派设计.md`
+目标:让设定集从骨架模板进入“可规划、可写作”的状态。
 
-执行规则(必须):
-- 只做增量补齐,不清空、不重写整文件。
-- 优先补齐“可执行字段”:角色定位、势力关系、能力边界、代价规则、反派层级映射。
-- 若总纲与现有设定冲突,先列冲突并阻断,等待用户裁决后再改。
+必须补齐:
+- `设定集/世界观.md`:世界边界、社会结构、关键地点用途
+- `设定集/力量体系.md`:境界链、限制、代价与冷却
+- `设定集/主角卡.md`:欲望、缺陷、初始资源与限制
+- `设定集/反派设计.md`:小/中/大反派层级与镜像关系
 
-基线补齐最小要求:
-- `设定集/世界观.md`:世界规则边界、社会结构、关键地点用途。
-- `设定集/力量体系.md`:境界链/能力限制/代价与冷却。
-- `设定集/主角卡.md`:欲望、缺陷、初始资源与限制。
-- `设定集/反派设计.md`:小/中/大反派层级与主角镜像关系。
+硬规则:
+- 只增量补齐,不清空、不重写整文件
+- 发现冲突时先列出冲突并阻断
 
-## 3) Select volume
-- Offer choices from 总纲.md (卷名 + 章节范围).
-- Confirm any special requirement (tone, POV emphasis, romance, etc.).
-If 总纲缺少卷名/章节范围/核心冲突/卷末高潮,先补问并更新总纲,再继续。
+### Step 3:选择目标卷并确认范围
 
-## 4) Generate volume beat sheet (节拍表)
-目标:先把本卷“承诺→危机递增→中段反转→最低谷→大兑现+新钩子”钉死,避免卷中段漂移。
+必须确认:
+- 卷名
+- 章节范围
+- 核心冲突
+- 是否存在特殊要求,例如视角、情感线、题材偏移
 
-Load template:
-```bash
-cat "${SKILL_ROOT}/../../templates/output/大纲-卷节拍表.md"
-```
+### Step 4:生成卷节拍表
 
-Must satisfy (hard requirements):
-- **中段反转(必填)**:不得留空;若无,写 `无(理由:...)`
-- **危机链**:至少 3 次递增(表格 1-3 行不得空)
-- **卷末新钩子**:必须能落到“最后一章的章末未闭合问题”
+执行前加载模板:
 
-Write output:
 ```bash
-@'
-{beat_sheet_content}
-'@ | Set-Content -Encoding UTF8 "$PROJECT_ROOT/大纲/第{volume_id}卷-节拍表.md"
+cat "${SKILL_ROOT}/../../templates/output/大纲-卷节拍表.md"
 ```
 
-Completion criteria:
-- `大纲/第{volume_id}卷-节拍表.md` 存在且非空
-- Step 4/5 能直接引用 Catalyst / 中段反转 / 最低谷 / 大兑现 / 新钩子来锚定节奏
+硬要求:
+- 必须填写中段反转;若确实没有,写“无(理由:...)”
+- 危机链至少 3 次递增
+- 卷末新钩子必须能落到最后一章的章末未闭合问题
 
-## 4.5) Generate volume timeline (时间线表)
+输出文件:`大纲/第{volume_id}卷-节拍表.md`
 
-目标:为本卷建立时间轴基准,确保章节间时间推进逻辑自洽,避免"第一章灾变第二章火拼"的时间跳跃问题。
+### Step 5:生成卷时间线表
+
+执行前加载模板:
 
-Load template:
 ```bash
 cat "${SKILL_ROOT}/../../templates/output/大纲-卷时间线.md"
 ```
 
-Must satisfy (hard requirements):
-- **时间基准(必填)**:明确本卷使用的时间体系(末世第X天/仙历年月/现代日期)
-- **本卷时间跨度(必填)**:本卷覆盖的时间范围
-- **关键倒计时事件**:若有时限性事件(物资耗尽/大比开始/截止日期),必须列出并标注 D-N
+硬要求:
+- 必须明确时间体系
+- 必须明确本卷时间跨度
+- 有倒计时事件时必须列出并标记 D-N
 
-Write output:
-```bash
-@'
-{timeline_content}
-'@ | Set-Content -Encoding UTF8 "$PROJECT_ROOT/大纲/第{volume_id}卷-时间线.md"
-```
+输出文件:`大纲/第{volume_id}卷-时间线.md`
+
+### Step 6:生成卷纲骨架
 
-Completion criteria:
-- `大纲/第{volume_id}卷-时间线.md` 存在且非空
-- 时间基准和本卷跨度已明确
-- 若存在倒计时事件,已在表中列出
+必须加载:
 
-## 5) Generate volume skeleton
-Load genre profile and apply standards:
 ```bash
 cat "${SKILL_ROOT}/../../references/genre-profiles.md"
 cat "${SKILL_ROOT}/../../references/shared/strand-weave-pattern.md"
 ```
 
-Optional (only if爽点结构需要细化):
-```bash
-cat "${SKILL_ROOT}/../../references/shared/cool-points-guide.md"
-```
+按需加载:
 
-Optional (only if需要补强卷级冲突链与强度分层):
 ```bash
+cat "${SKILL_ROOT}/../../references/shared/cool-points-guide.md"
 cat "${SKILL_ROOT}/references/outlining/conflict-design.md"
+cat "${SKILL_ROOT}/references/outlining/genre-volume-pacing.md"
+cat "$PROJECT_ROOT/.webnovel/idea_bank.json"
 ```
 
-Load beat sheet (must exist):
-```bash
-cat "$PROJECT_ROOT/大纲/第{volume_id}卷-节拍表.md"
-```
+卷纲必须明确:
+- 卷摘要
+- 关键人物与反派层级
+- Strand 分布
+- 爽点密度规划
+- 伏笔规划
+- 约束触发规划
 
-Extract for current genre:
-- Strand 比例(Quest/Fire/Constellation)
-- 爽点密度标准(每章最低/推荐)
-- 钩子类型偏好
-
-### Strand Weave 规划策略
-Based on genre profile, distribute chapters:
-- **Quest Strand** (主线推进): 55-65% 章节
-  - 目标明确、进展可见、有阶段性成果
-  - 例:突破境界、完成任务、获得宝物
-- **Fire Strand** (情感/关系): 20-30% 章节
-  - 人物关系变化、情感冲突、团队动态
-  - 例:与女主互动、师徒矛盾、兄弟背叛
-- **Constellation Strand** (世界/谜团): 10-20% 章节
-  - 世界观揭示、伏笔埋设、谜团推进
-  - 例:发现古老秘密、揭示反派阴谋、世界真相
-
-**Weaving pattern** (recommended):
-- 每 3-5 章切换主导 Strand
-- 高潮章节可多 Strand 交织
-- 卷末 3-5 章集中 Quest Strand
-
-For 电竞/直播文/克苏鲁, apply dedicated volume pacing template:
-```bash
-cat "${SKILL_ROOT}/references/outlining/genre-volume-pacing.md"
-```
+### Step 7:批量生成章纲
 
-### 爽点密度规划策略
-Based on genre profile:
-- **常规章节**: 1-2 个小爽点(强度 2-3)
-- **关键章节**: 2-3 个爽点,至少 1 个中爽点(强度 4-5)
-- **高潮章节**: 3-4 个爽点,至少 1 个大爽点(强度 6-7)
+批次规则:
+- 默认按 `10章/批`
+- 复杂题材或多线并进时可降到 `8章/批`
+- 简单升级流可放宽到 `12章/批`
+- 不建议单批超过 `12章`
 
-**Distribution rule**:
-- 每 5-8 章至少 1 个关键章节
-- 每卷至少 1 个高潮章节(通常在卷末)
+按需加载:
 
-### 约束触发规划策略
-If idea_bank.json exists:
 ```bash
-cat "$PROJECT_ROOT/.webnovel/idea_bank.json"
+cat "${SKILL_ROOT}/../../references/reading-power-taxonomy.md"
+cat "${SKILL_ROOT}/references/outlining/chapter-planning.md"
 ```
 
-Calculate trigger frequency:
-- **反套路规则**: 每 N 章触发 1 次
-  - N = max(5, 总章数 / 10)
-  - 例:50 章卷 → 每 5 章触发
-  - 例:100 章卷 → 每 10 章触发
-- **硬约束**: 贯穿全卷,在章节目标/爽点设计中体现
-- **主角缺陷**: 每卷至少 2 次成为冲突来源
-- **反派镜像**: 反派出场章节必须体现镜像对比
+每章必须包含:
+- 目标
+- 阻力
+- 代价
+- 时间锚点
+- 章内时间跨度
+- 与上章时间差
+- 倒计时状态
+- 爽点
+- Strand
+- 反派层级
+- 视角/主角
+- 关键实体
+- 本章变化
+- 章末未闭合问题
+- 钩子
+- `章节起点(CBN)`
+- `推进节点(CPNs)`
+- `章节终点(CEN)`
+- `必须覆盖节点`
+- `本章禁区`
 
-Use this template and fill from 总纲 + idea_bank:
+#### 结构化节点规范
 
-```markdown
-# 第 {volume_id} 卷:{卷名}
+节点格式统一为:
 
-> 章节范围: 第 {start} - {end} 章
-> 核心冲突: {conflict}
-> 卷末高潮: {climax}
+`主体 | 动作/变化 | 对象/结果`
 
-## 卷摘要
-{2-3 段落概述}
+说明:
+- 这里的节点是写作执行骨架,不追求严格语法学 SVO。
+- `动作/变化` 可以表示行动、判断、意识变化或状态转移。
+- `对象/结果` 可以是人、物、地点,也可以是结果状态。
 
-## 关键人物与反派
-- 主要登场角色:
-- 反派层级:
+示例:
+- `萧炎 | 抵达 | 迦南学院入口`
+- `萧炎 | 展示 | 异火控制力`
+- `药老 | 对萧炎产生 | 明确兴趣`
+- `萧炎 | 意识到 | 学院考核远比预想更严苛`
 
-## Strand Weave 规划
-| 章节范围 | 主导 Strand | 内容概要 |
-|---------|------------|---------|
+结构规则:
+- 每章固定 `1 个 CBN`
+- 每章 `2-4 个 CPN`
+- 每章固定 `1 个 CEN`
+- 相邻章节 `CEN -> 下一章 CBN` 必须逻辑承接(首章和末章除外)
+- `CPNs` 必须按时间顺序排列
 
-## 爽点密度规划
-| 章节 | 爽点类型 | 具体内容 | 强度 |
-|------|---------|---------|------|
+必须覆盖规则:
+- 每章必须覆盖节点最多 `4` 个
+- 建议为:`CBN + CEN + 1~2 个核心 CPN`
+- 可选节点只作为写作建议,不得作为 fail 主依据
 
-## 伏笔规划
-| 章节 | 操作 | 伏笔内容 |
-|------|------|---------|
+本章禁区规则:
+- 不超过 `5` 条
+- 只写本章绝对不能发生的硬禁区
+- 不写风格类建议,不写空泛表述
 
-## 约束触发规划(如有)
-- 反套路规则:每 N 章触发一次
-- 硬约束:贯穿全卷
-```
+向后兼容:
+- 若旧项目章纲缺失 `CBN/CPNs/CEN/必须覆盖节点/本章禁区` 字段,下游流程正常执行,仅跳过结构化检查
 
-## 6) Generate chapter outlines (batched)
-Batching rule:
-- ≤20 章:1 批
-- 21–40 章:2 批
-- 41–60 章:3 批
-- >60 章:4+ 批
+输出文件:`大纲/第{volume_id}卷-详细大纲.md`
 
-Optional (only if需要钩子/节奏细分):
-```bash
-cat "${SKILL_ROOT}/../../references/reading-power-taxonomy.md"
-```
-
-Optional (only if需要章节微结构/标题策略细化):
-```bash
-cat "${SKILL_ROOT}/references/outlining/chapter-planning.md"
-```
-
-### Chapter generation strategy
-For each chapter, determine:
-
-**1. Strand assignment** (follow volume skeleton distribution)
-- Quest: 主线任务推进、目标达成、能力提升
-- Fire: 人物关系、情感冲突、团队动态
-- Constellation: 世界揭示、伏笔埋设、谜团推进
-
-**2. 爽点设计** (based on Strand and position)
-- Quest Strand → 成就爽点(打脸、逆袭、突破)
-- Fire Strand → 情感爽点(认可、保护、告白)
-- Constellation Strand → 认知爽点(真相、预言、身份)
-
-**3. 钩子设计** (based on next chapter's Strand)
-- 悬念钩子:提出问题、制造危机
-- 承诺钩子:预告奖励、暗示转折
-- 情感钩子:关系变化、角色危机
-
-**4. 反派层级** (based on volume skeleton)
-- 无:日常章节、修炼章节、关系章节
-- 小:小冲突、小反派、局部对抗
-- 中:中反派出场、重要冲突、阶段性对抗
-- 大:大反派出场、核心冲突、卷级高潮
-
-**5. 关键实体** (new or important)
-- 新角色:姓名 + 一句话定位
-- 新地点:名称 + 一句话描述
-- 新物品:名称 + 功能
-- 新势力:名称 + 立场
-
-**6. 约束检查** (if idea_bank exists)
-- 是否触发反套路规则?
-- 是否体现硬约束?
-- 是否展现主角缺陷?
-- 是否体现反派镜像?
-
-Chapter format (include 反派层级 for context-agent):
-
-```markdown
-### 第 {N} 章:{标题}
-- 目标: {20字以内}
-- 阻力: {20字以内}
-- 代价: {20字以内}
-- 时间锚点: {末世第X天 时段/仙历X年X月X日/具体日期+时段}
-- 章内时间跨度: {如 3小时/半天/1天}
-- 与上章时间差: {如 紧接/6小时/1天/跨夜}
-- 倒计时状态: {事件A D-3 -> D-2 / 无}
-- 爽点: {类型} - {30字以内}
-- Strand: {Quest|Fire|Constellation}
-- 反派层级: {无/小/中/大}
-- 视角/主角: {主角A/主角B/女主/群像}
-- 关键实体: {新增或重要出场}
-- 本章变化: {30字以内,优先可量化变化}
-- 章末未闭合问题: {30字以内}
-- 钩子: {类型} - {30字以内}
-```
-
-**时间字段说明**:
-- **时间锚点**:本章发生的具体时间点,必须与时间线表一致
-- **章内时间跨度**:本章内容覆盖的时间长度
-- **与上章时间差**:与上一章结束时间的间隔
-  - 紧接:无时间间隔,直接承接
-  - 跨夜:过夜但不超过 12 小时
-  - 具体时长:如 6小时、1天、3天
-- **倒计时状态**:若存在倒计时事件,标注推进情况(D-N → D-(N-1))
-
-**字段说明**:
-- **章末未闭合问题**:本章结尾必须保留的“未闭合决策/问题”,用于驱动读者点下一章。
-  - 规则:必须与 **钩子** 的类型/强度一致;不得出现“钩子很强但问题很虚”的错配。
-- **钩子**:本章应设置的章末钩子(规划用)
-  - 例:悬念钩 - 神秘人身份即将揭晓
-  - 意思是:本章结尾要设置这个悬念钩子
-  - 下章 context-agent 会读取 chapter_meta[N].hook(实际实现的钩子),生成"接住上章"指导
-  - 钩子类型参考:悬念钩 | 危机钩 | 承诺钩 | 情绪钩 | 选择钩 | 渴望钩
-
-Save after each batch:
-```bash
-@'
-{batch_content}
-'@ | Add-Content -Encoding UTF8 "$PROJECT_ROOT/大纲/第{volume_id}卷-详细大纲.md"
-```
-
-## 7) Enrich existing setting files from volume outline
-目标:卷纲写完后,把本卷新增事实写回“现有设定集文件”,确保后续写作可直接读取。
+### Step 8:把新增设定写回现有设定集
 
 输入来源:
-- `大纲/第{volume_id}卷-节拍表.md`
-- `大纲/第{volume_id}卷-详细大纲.md`
-- 现有设定集文件(世界观/力量体系/主角卡/主角组/女主卡/反派设计)
-
-写回策略(必须):
-- 仅增量补充相关段落,不覆盖整文件。
-- 新增角色:写入对应角色卡或角色组条目(含首次出场章、关系、红线)。
-- 新增势力/地点/规则:写入世界观或力量体系对应章节。
-- 新增反派层级信息:写入反派设计并保持小/中/大层级一致。
-
-冲突处理(硬规则):
-- 若卷纲新增信息与总纲或已确认设定冲突,标记 `BLOCKER` 并停止 state 更新。
-- 只有冲突裁决完成后,才允许继续更新设定并进入保存步骤。
-
-## 8) Validate + save
-### Validation checks (must pass all)
-
-**1. 爽点密度检查**
-- 每章 ≥1 小爽点(强度 2-3)
-- 每 5-8 章至少 1 个关键章节(强度 4-5)
-- 每卷至少 1 个高潮章节(强度 6-7)
-
-**2. Strand 比例检查**
-Count chapters by Strand and compare with genre profile:
-- Quest: 应占 55-65%
-- Fire: 应占 20-30%
-- Constellation: 应占 10-20%
-
-If deviation > 15%, adjust chapter assignments.
-
-**3. 总纲一致性检查**
-- 卷核心冲突是否贯穿章节?
-- 卷末高潮是否在最后 3-5 章体现?
-- 关键人物是否按计划登场?
-
-**4. 约束触发频率检查** (if idea_bank exists)
-- 反套路规则触发次数 ≥ 总章数 / N(N = max(5, 总章数/10))
-- 硬约束在至少 50% 章节中体现
-- 主角缺陷至少 2 次成为冲突来源
-- 反派镜像在反派出场章节中体现
-
-**5. 完整性检查**
-Every chapter must have:
-- 目标(20 字以内)
-- 阻力(20 字以内)
-- 代价(20 字以内)
-- 时间锚点(必填)
-- 章内时间跨度(必填)
-- 与上章时间差(必填)
-- 倒计时状态(若有倒计时事件则必填)
-- 爽点(类型 + 30 字描述)
-- Strand(Quest/Fire/Constellation)
-- 反派层级(无/小/中/大)
-- 视角/主角
-- 关键实体(至少 1 个)
-- 本章变化(30 字以内)
-- 章末未闭合问题(30 字以内)
-- 钩子(类型 + 30 字描述)
-
-**6. 时间线一致性检查(新增)**
-- 时间线表文件存在:`大纲/第{volume_id}卷-时间线.md`
-- 所有章节时间锚点已填写
-- 时间单调递增(不得回跳,除非明确标注为闪回)
-- 倒计时推进正确(D-5 → D-4 → D-3,不得跳跃)
-- 大跨度时间跳跃(>3天)必须有过渡章说明或明确标注
-
-**7. 设定补全检查**
-- 本卷涉及的新角色/势力/规则已回写到现有设定集文件
-- 所有新增条目可回溯到本卷章纲章节
-- `BLOCKER` 数量为 0;若 >0,必须先裁决,不得进入 state 更新
-
-Update state (include chapters range):
+- 卷节拍表
+- 卷时间线表
+- 卷详细大纲
+- 现有设定集文件
+
+写回规则:
+- 只增量补充相关段落
+- 新角色写入角色卡或角色组
+- 新势力、地点、规则写入世界观或力量体系
+- 新反派层级写入反派设计
+
+硬规则:
+- 若发现与总纲或既有设定冲突,标记 `BLOCKER` 并停止后续更新
+
+### Step 9:验证、保存并更新状态
+
+必须通过以下检查:
+- 节拍表存在且非空
+- 时间线表存在且非空
+- 详细大纲存在且非空
+- 每章时间字段齐全
+- 时间线单调递增
+- 倒计时推进正确
+- 新设定已回写到现有设定集
+- `BLOCKER=0`
+- 有节点时,相邻章节 `CEN -> CBN` 无明显逻辑冲突
+- 有节点时,每章必须覆盖节点不超过 `4` 个
+
+更新状态:
+
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" update-state -- \
   --volume-planned {volume_id} \
   --chapters-range "{start}-{end}"
 ```
 
-Final check:
-- 节拍表文件已写入:`大纲/第{volume_id}卷-节拍表.md`
-- 时间线表文件已写入:`大纲/第{volume_id}卷-时间线.md`
-- 章纲文件已写入:`大纲/第{volume_id}卷-详细大纲.md`
-- 设定集已完成基线补齐与本卷增量补充(原文件内可见)
-- 每章包含:目标/阻力/代价/时间锚点/章内时间跨度/与上章时间差/爽点/Strand/反派层级/视角/关键实体/本章变化/章末未闭合问题/钩子
-- 时间线单调递增,倒计时推进正确
-- 与总纲冲突/高潮一致,约束触发频率合理(如有 idea_bank)
-
-### Hard fail conditions (must stop)
-- 节拍表文件不存在或为空
-- 节拍表中段反转缺失(未按“必填/无(理由)”规则填写)
-- **时间线表文件不存在或为空**
-- 章纲文件不存在或为空
-- 任一章节缺少:目标/阻力/代价/时间锚点/章内时间跨度/与上章时间差/爽点/Strand/反派层级/视角/关键实体/本章变化/章末未闭合问题/钩子
-- **任一章节时间字段(时间锚点/章内时间跨度/与上章时间差)缺失**
-- **时间回跳且未标注为闪回**
-- **倒计时算术冲突(如 D-5 直接跳到 D-2)**
-- **重大事件发生时间与前章间隔不足且无合理解释(如末世第1天建帮派)**
+## 硬失败条件
+
+- 节拍表不存在或为空
+- 中段反转缺失且未给出理由
+- 时间线表不存在或为空
+- 详细大纲不存在或为空
+- 任一章节缺少时间字段
+- 时间回跳且未标注闪回
+- 倒计时算术冲突
 - 与总纲核心冲突或卷末高潮明显冲突
-- 设定集基线未补齐,或本卷增量未回写到现有设定集
 - 存在 `BLOCKER` 未裁决
-- 约束触发频率不足(当 idea_bank 启用时)
-
-### Rollback / recovery
-If any hard fail triggers:
-1. Stop and list the failing items.
-2. Re-generate only the failed batch (do not overwrite the whole file).
-3. If the last batch is invalid, remove that batch and rewrite it.
-4. Only update state after Final check passes.
-
-Next steps:
-- 继续规划下一卷 → /webnovel-plan
-- 开始写作 → /webnovel-write
+
+## 恢复规则
+
+1. 只重做失败批次,不覆盖整卷文件。
+2. 最后一个批次无效时,只删除并重写该批次。
+3. 仅在全部验证通过后更新状态。

+ 46 - 150
webnovel-writer/skills/webnovel-resume/SKILL.md

@@ -1,202 +1,98 @@
 ---
 name: webnovel-resume
-description: Recovers interrupted webnovel tasks with precise workflow state tracking. Detects interruption point and provides safe recovery options. Activates when user wants to resume or /webnovel-resume.
+description: 检测中断点并按安全策略恢复小说工作流。
 allowed-tools: Read Bash AskUserQuestion
 ---
 
 # Task Resume Skill
 
-## Project Root Guard(必须先确认)
+## 目标
 
-- Claude Code 的“工作区根目录”不一定等于“书项目根目录”。常见结构:工作区为 `D:\wk\xiaoshuo`,书项目为 `D:\wk\xiaoshuo\凡人资本论`。
-- 必须先解析真实书项目根(必须包含 `.webnovel/state.json`),后续所有读写路径都以该目录为准。
+- 检测真实中断点,禁止凭感觉续写。
+- 让用户基于清晰风险选择恢复策略。
+- 恢复时只做最小清理,不擅自扩写半成品。
+
+## 执行流程
+
+### Step 1:解析项目根目录并加载恢复协议
 
-环境设置(bash 命令执行前):
 ```bash
 export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
-
-if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume" ]; then
-  echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume" >&2
-  exit 1
-fi
 export SKILL_ROOT="${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume"
-
-if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
-  echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/scripts" >&2
-  exit 1
-fi
 export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
-
 export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
-```
-
-## Workflow Checklist
-
-Copy and track progress:
-
-```
-任务恢复进度:
-- [ ] Step 1: 加载恢复协议 (cat "${SKILL_ROOT}/references/workflow-resume.md")
-- [ ] Step 2: 加载数据规范 (cat "${SKILL_ROOT}/references/system-data-flow.md")
-- [ ] Step 3: 确认上下文充足
-- [ ] Step 4: 检测中断状态
-- [ ] Step 5: 展示恢复选项 (AskUserQuestion)
-- [ ] Step 6: 执行恢复
-- [ ] Step 7: 继续任务 (可选)
-```
-
----
-
-## Reference Loading Levels (strict, lazy)
-
-- L0: 不加载任何参考,直到确认存在中断恢复需求。
-- L1: 只加载恢复协议主文件。
-- L2: 仅在数据一致性检查时加载数据规范。
-
-### L1 (minimum)
-- [workflow-resume.md](references/workflow-resume.md)
-
-### L2 (conditional)
-- [system-data-flow.md](references/system-data-flow.md)(仅在需要核对状态字段/恢复策略时)
-
-## Step 1: 加载恢复协议(必须执行)
-
-```bash
 cat "${SKILL_ROOT}/references/workflow-resume.md"
 ```
 
-**核心原则**(读取后应用)
-- **禁止智能续写**: 上下文丢失风险高
-- **必须检测后恢复**: 不猜测中断点
-- **必须用户确认**: 不自动恢复
+核心原则:
+- 禁止智能续写半成品
+- 必须先检测再恢复
+- 必须用户确认后执行
 
-## Step 2: 加载数据规范
+### Step 2:按需加载数据规范
 
 ```bash
 cat "${SKILL_ROOT}/references/system-data-flow.md"
 ```
 
-## Step 3: 确认上下文充足
+要求:
+- 仅在需要核对状态字段、恢复策略或数据一致性时加载
 
-**检查清单**:
-- [ ] 恢复协议已理解
-- [ ] Step 难度分级已知
-- [ ] 状态结构已理解
-- [ ] "删除重来" vs "智能续写" 原则已明确
+### Step 3:确认上下文充足
 
-**如有缺失 → 返回对应 Step**
+必须确认:
+- 已理解恢复协议
+- 已理解状态结构
+- 已明确“删除重来”优先于“智能续写”
 
-## Step 难度分级(来自 workflow-resume.md)
-
-| Step | 难度 | 恢复策略 |
-|------|------|---------|
-| Step 1 | ⭐ | 直接重新执行 |
-| Step 1.5 | ⭐ | 重新设计 |
-| Step 2A | ⭐⭐ | 删除半成品,重新开始 |
-| Step 2B | ⭐⭐ | 继续适配或回到 2A |
-| Step 3 | ⭐⭐⭐ | 用户决定:重审或跳过 |
-| Step 4 | ⭐⭐ | 继续润色或删除重写 |
-| Step 5 | ⭐⭐ | 重新运行(幂等) |
-| Step 6 | ⭐⭐⭐ | 检查暂存区,决定提交/回滚 |
-
-## Step 4: 检测中断状态
+### Step 4:检测中断状态
 
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow detect
 ```
 
-**输出情况**
-- 无中断 → 结束流程,通知用户
-- 检测到中断 → 继续 Step 5
+结果处理:
+- 无中断:直接结束并通知用户
+- 有中断:进入 Step 5
 
-## Step 5: 展示恢复选项(必须执行)
+### Step 5:展示恢复选项并让用户决策
 
-**展示给用户**
-- 任务命令和参数
-- 中断时间已过时长
+必须展示:
+- 原任务命令和参数
+- 中断时间已过时长
 - 已完成步骤
-- 当前中断步骤
+- 当前中断步骤
 - 剩余步骤
-- 恢复选项及风险等级
-
-**示例输出**:
-
-```
-🔴 检测到中断任务:
-
-任务:/webnovel-write 7
-中断位置:Step 2 - 章节内容生成中
+- 恢复选项与风险说明
 
-已完成:
-  ✅ Step 1: 上下文加载
+### Step 6:执行恢复操作
 
-未完成:
-  ⏸️ Step 2: 章节内容(已写1500字)
-  ⏹️ Step 3-7: 未开始
+选项 A:删除半成品并清理工作流状态
 
-恢复选项:
-A) 删除半成品,从Step 1重新开始(推荐)
-B) 回滚到Ch6,放弃Ch7所有进度
-
-请选择(A/B):
-```
-
-## Step 6: 执行恢复
-
-**选项 A - 删除重来**(推荐):
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow cleanup --chapter {N} --confirm
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clear
 ```
 
-**选项 B - Git 回滚**:
-```bash
-git -C "$PROJECT_ROOT" reset --hard ch{N-1:04d}
-python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clear
-```
+选项 B:按既有版本回退,再清理工作流状态
 
-## Step 7: 继续任务(可选)
-
-如用户选择立即继续:
 ```bash
-/{original_command} {original_args}
-```
-
----
-
-## 特殊场景
-
-### Step 6 中断(成本高)
-
-```
-恢复选项:
-A) 重新执行双章审查(成本:~$0.15)⚠️
-B) 跳过审查,继续下一章(可后续补审)
-```
-
-### Step 4 中断(部分状态)
-
-```
-⚠️ state.json 可能部分更新
-
-A) 检查并修复 state.json
-B) 回滚到上一章(安全)
+git -C "$PROJECT_ROOT" rev-parse --verify "ch{N-1:04d}"
+git -C "$PROJECT_ROOT" switch --detach "ch{N-1:04d}"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clear
 ```
 
-### 长时间中断(>1小时)
-
-```
-⚠️ 中断已超过1小时
+说明:
+- `workflow cleanup --confirm` 与 Git 回退都属于高风险操作,执行前必须获得用户明确确认。
+- 若用户只是要保留现场排查问题,不应执行上述清理或回退。
 
-上下文丢失风险高
-建议重新开始而非续写
-```
+### Step 7:按用户意愿继续任务
 
----
+若用户要求立即继续,则执行原始命令;若未要求,则仅完成恢复并结束。
 
 ## 禁止事项
 
-- ❌ 智能续写半成品内容
-- ❌ 自动选择恢复策略
-- 跳过中断检测
-- ❌ 不验证就修复 state.json
+- 禁止智能续写半成品
+- 禁止自动替用户选择恢复策略
+- 禁止跳过中断检测
+- 禁止在未验证前修复 `state.json`

+ 38 - 32
webnovel-writer/skills/webnovel-resume/references/workflow-resume.md

@@ -4,7 +4,7 @@ purpose: 任务恢复时加载,指导中断恢复流程
 ---
 
 <context>
-此文件用于中断任务恢复。Claude 已知错误处理流程,这里只补充网文创作工作流特定的 Step 难度分级和恢复策略。
+此文件用于中断任务恢复。Claude 已知通用错误处理流程,这里只补充网文创作工作流的步骤难度分级和恢复策略。
 </context>
 
 <instructions>
@@ -14,60 +14,66 @@ purpose: 任务恢复时加载,指导中断恢复流程
 | Step | 名称 | 影响 | 难度 | 默认策略 |
 |------|------|------|------|----------|
 | Step 1 | Context Agent | 无副作用(仅读取) | ⭐ | 直接重新执行 |
-| Step 1.5 | 章节设计 | 结构未固化 | ⭐ | 重新设计 |
-| Step 2A | 生成粗稿 | 半成品章节文件 | ⭐⭐ | **删除半成品**,从 Step 1 重新开始 |
-| Step 2B | 风格适配 | 部分改写内容 | ⭐⭐ | 继续适配或回到 2A |
+| Step 2A | 生成粗稿 | 半成品章节文件 | ⭐⭐ | 删除半成品,从 Step 1 重新开始 |
+| Step 2B | 风格适配 | 部分改写内容 | ⭐⭐ | 继续适配或回到 Step 2A |
 | Step 3 | 审查 | 审查未完成 | ⭐⭐⭐ | 用户决定:重审或跳过 |
 | Step 4 | 网文化润色 | 部分润色的文件 | ⭐⭐ | 继续润色或删除重写 |
-| Step 5 | Data Agent | 实体未提取完 | ⭐⭐ | 重新运行(幂等) |
-| Step 6 | Git 备份 | 未提交 | ⭐⭐⭐ | 检查暂存区,决定提交/回滚 |
+| Step 5 | Data Agent | 实体、摘要、长期记忆未写完 | ⭐⭐ | 重新运行(幂等) |
+| Step 6 | Git 备份 | 未提交 | ⭐⭐⭐ | 检查暂存区,决定提交或保留现场 |
 
 ## 恢复流程
 
-### Phase 1: 检测中断状态
+### Step 1:检测中断状态
 
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow detect
 ```
 
-### Phase 2: 询问用户
+### Step 2:询问用户
 
-**必须展示**:
+必须展示:
 - 任务命令和参数
 - 中断时间和位置
 - 已完成步骤
 - 恢复选项和风险等级
 
-### Phase 3: 执行恢复
+### Step 3:执行恢复
+
+选项 A:删除半成品后重新开始
 
-**选项 A(推荐)**: 删除半成品重新开始
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow cleanup --chapter {N} --confirm
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clear
 /webnovel-write {N}
 ```
 
-**选项 B**: 回滚到上一章
+选项 B:切到上一稳定标签,再清理中断状态
+
 ```bash
-git reset --hard ch{N-1:04d}
+git -C "$PROJECT_ROOT" rev-parse --verify "ch{N-1:04d}"
+git -C "$PROJECT_ROOT" switch --detach "ch{N-1:04d}"
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clear
 ```
 
-## 为什么删除而不续写?
+硬规则:
+- `workflow cleanup --confirm` 与 Git 切换都属于高风险操作,执行前必须获得用户明确确认。
+- 若用户要保留现场排查问题,禁止执行清理或回退。
+
+## 为什么删除而不续写
 
-1. **质量保证**: 半成品可能包含未完成句子、逻辑断裂
-2. **上下文丢失**: 新会话无法记住之前创作思路
-3. **防幻觉**: 续写容易产生前后矛盾
-4. **成本可控**: 重新生成 < 修复半成品 + 审查
+1. 半成品可能包含未完成句子、逻辑断裂
+2. 新会话缺失创作时的隐含上下文。
+3. 直接续写更容易引入前后矛盾。
+4. 重新生成的成本通常低于修半成品加重审。
 
 ## 特殊场景
 
 | 场景 | 策略 |
 |------|------|
-| 超过24小时 | 推荐回滚(上下文完全丢失) |
-| 超过1小时 | 推荐重新开始 |
-| 多个半成品 | 删除所有,从稳定状态重新开始 |
-| workflow_state.json不存在 | 启发式检测 state.json vs 文件 |
+| 超过 24 小时 | 推荐重新开始 |
+| 超过 1 小时 | 默认删除半成品后重来 |
+| 多个半成品 | 删除所有半成品,从稳定状态重新开始 |
+| workflow_state.json 不存在 | 启发式检测 `state.json` 与正文文件 |
 
 </instructions>
 
@@ -76,7 +82,7 @@ python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clea
 <example>
 <input>Step 2A 中断(最常见)</input>
 <output>
-🔴 检测到中断任务:
+检测到中断任务:
 
 任务:/webnovel-write 7
 中断位置:Step 2A - 章节内容生成中
@@ -86,11 +92,11 @@ python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clea
 
 未完成:
   ⏸️ Step 2A: 生成章节内容(已写1500字)
-  ⏹️ Step 3-6: 未开始
+  ⏹️ Step 2B-6: 未开始
 
 恢复选项:
-A) 删除半成品,从 Step 1 重新开始(推荐)
-B) 回滚到 Ch6,放弃 Ch7 所有进度
+A) 删除半成品,从 Step 1 重新开始(推荐)
+B) 切到 Ch6 稳定标签,放弃 Ch7 当前进度
 
 请选择(A/B):
 </output>
@@ -100,12 +106,12 @@ B) 回滚到 Ch6,放弃 Ch7 所有进度
 <input>Step 3 中断(审查)</input>
 <output>
 恢复选项:
-A) 重新执行审查 ⚠️
-   - 调用6个审查员
+A) 重新执行审查
+   - 调用审查员
    - 生成审查报告
    - 继续 Step 4 润色
 
-B) 跳过审查,直接润色(推荐)
+B) 跳过审查,直接润色
    - 不生成审查报告
    - 可后续用 /webnovel-review 补审
 
@@ -117,7 +123,7 @@ B) 跳过审查,直接润色(推荐)
 <input>Step 4 中断(润色)</input>
 <output>
 恢复选项:
-A) 继续润色(推荐)⭐
+A) 继续润色
    - 打开并继续润色实际章节文件(优先 `正文/第0007章-章节标题.md`)
    - 保存文件
    - 继续 Step 5(Data Agent)
@@ -135,6 +141,6 @@ B) 删除润色稿,从 Step 2A 重写
 <errors>
 ❌ 智能续写半成品 → ✅ 删除后重新生成
 ❌ 自动决定恢复策略 → ✅ 必须用户确认
-❌ 跳过中断检测 → ✅ 先运行 workflow_manager.py detect
-❌ 修复 state.json 不验证 → ✅ 逐字段检查一致性
+❌ 跳过中断检测 → ✅ 先运行 workflow detect
+❌ 不确认就做高风险清理 → ✅ 先获得用户明确确认
 </errors>

+ 113 - 111
webnovel-writer/skills/webnovel-review/SKILL.md

@@ -1,194 +1,196 @@
 ---
 name: webnovel-review
-description: Reviews chapter quality with checker agents and generates reports. Use when the user asks for a chapter review or runs /webnovel-review.
+description: 使用审查 Agent 评估章节质量,生成报告并写回审查指标。
 allowed-tools: Read Grep Write Edit Bash Task AskUserQuestion
 ---
 
 # Quality Review Skill
 
-## Project Root Guard(必须先确认)
+## 目标
 
-- Claude Code 的“工作区根目录”不一定等于“书项目根目录”。常见结构:工作区为 `D:\wk\xiaoshuo`,书项目为 `D:\wk\xiaoshuo\凡人资本论`。
-- 必须先解析真实书项目根(必须包含 `.webnovel/state.json`),后续所有读写路径都以该目录为准。
+- 解析真实书项目根目录,按统一流程完成章节审查。
+- 调用审查 Agent 生成结构化问题列表、综合评分与审查报告。
+- 把审查指标写入 `index.db`,并把审查记录写回 `state.json`。
+- 若存在关键问题,明确交给用户决定是否立即返工。
+
+## 执行流程
+
+### Step 1:解析项目根目录并建立环境变量
 
-环境设置(bash 命令执行前):
 ```bash
 export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
-
-if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-review" ]; then
-  echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/skills/webnovel-review" >&2
-  exit 1
-fi
 export SKILL_ROOT="${CLAUDE_PLUGIN_ROOT}/skills/webnovel-review"
-
-if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
-  echo "ERROR: 未设置 CLAUDE_PLUGIN_ROOT 或缺少目录: ${CLAUDE_PLUGIN_ROOT}/scripts" >&2
-  exit 1
-fi
 export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
-
 export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
 ```
 
-## 0.5 工作流断点(best-effort,不得阻断主流程)
+要求:
+- `PROJECT_ROOT` 必须包含 `.webnovel/state.json`
+- 任一关键目录不存在时立即阻断
 
-> 目标:让 `/webnovel-resume` 能基于真实断点恢复。即使 workflow_manager 出错,也**只记录警告**,审查继续。
+### Step 2:记录工作流断点(best-effort)
 
-推荐(bash):
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-task --command webnovel-review --chapter {end} || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 1" --step-name "解析项目根目录" || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 1" --artifacts '{"project_root_ready":true}' || true
 ```
 
-Step 映射(必须与 `workflow_manager.py get_pending_steps("webnovel-review")` 对齐):
-- Step 1:加载参考
-- Step 2:加载项目状态
-- Step 3:并行调用检查员
-- Step 4:生成审查报告
-- Step 5:保存审查指标到 index.db
-- Step 6:写回审查记录到 state.json
-- Step 7:处理关键问题(AskUserQuestion)
-- Step 8:收尾(完成任务)
+要求:
+- 记录失败只记警告,不阻断主流程
+
+### Step 3:按需加载参考资料
 
-Step 记录模板(bash,失败不阻断):
 ```bash
-python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 1" --step-name "加载参考" || true
-python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 1" --artifacts '{"ok":true}' || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 2" --step-name "加载参考" || true
 ```
 
-## Review depth
+必读:
 
-- **Core (default)**: consistency / continuity / ooc / reader-pull
-- **Full (关键章/用户要求)**: core + high-point + pacing
+```bash
+cat "${SKILL_ROOT}/../../references/shared/core-constraints.md"
+```
 
-## Step 1: 加载参考(按需)
+按需加载:
 
-## References(按步骤导航)
+```bash
+cat "${SKILL_ROOT}/../../references/shared/cool-points-guide.md"
+cat "${SKILL_ROOT}/../../references/shared/strand-weave-pattern.md"
+cat "${SKILL_ROOT}/references/common-mistakes.md"
+cat "${SKILL_ROOT}/references/pacing-control.md"
+```
 
-- Step 1(必读,硬约束):[core-constraints.md](../../references/shared/core-constraints.md)
-- Step 1(可选,Full 或节奏/爽点相关问题):[cool-points-guide.md](../../references/shared/cool-points-guide.md)
-- Step 1(可选,Full 或节奏/爽点相关问题):[strand-weave-pattern.md](../../references/shared/strand-weave-pattern.md)
-- Step 1(可选,仅在返工建议需要时):[common-mistakes.md](references/common-mistakes.md)
-- Step 1(可选,仅在返工建议需要时):[pacing-control.md](references/pacing-control.md)
+规则:
+- 先判定 Core 或 Full 审查深度,再加载对应参考
+- 不得在未触发时一次性读完全部资料
 
-## Reference Loading Levels (strict, lazy)
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 2" --artifacts '{"references_loaded":true}' || true
+```
 
-- L0: 先确定审查深度(Core / Full),再加载参考。
-- L1: 只加载 References 区的“必读”条目。
-- L2: 仅在问题定位需要时加载 References 区的“可选”条目。
+### Step 4:加载项目状态与待审正文
 
-**必读**:
 ```bash
-cat "${SKILL_ROOT}/../../references/shared/core-constraints.md"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 3" --step-name "加载项目状态" || true
 ```
 
-**建议(Full 或需要时)**:
 ```bash
-cat "${SKILL_ROOT}/../../references/shared/cool-points-guide.md"
-cat "${SKILL_ROOT}/../../references/shared/strand-weave-pattern.md"
+cat "${PROJECT_ROOT}/.webnovel/state.json"
 ```
 
-**可选**:
+要求:
+- 明确当前章节范围与对应正文文件
+- 若缺少正文或状态文件,立即阻断
+
 ```bash
-cat "${SKILL_ROOT}/references/common-mistakes.md"
-cat "${SKILL_ROOT}/references/pacing-control.md"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 3" --artifacts '{"review_input_ready":true}' || true
 ```
 
-## Step 2: 加载项目状态(若存在)
+### Step 5:并行调用检查员并汇总结果
 
 ```bash
-cat "$PROJECT_ROOT/.webnovel/state.json"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 4" --step-name "并行调用检查员" || true
 ```
 
-## Step 3: 并行调用检查员(Task)
-
-**调用约束**:
-- 必须通过 `Task` 工具调用审查 subagent,禁止主流程直接内联审查结论。
-- 各 subagent 结果全部返回后再生成总评与优先级。
+必须通过 `Task` 调用审查子代理,禁止主流程伪造结论。
 
-**Core**:
+Core:
 - `consistency-checker`
 - `continuity-checker`
 - `ooc-checker`
 - `reader-pull-checker`
 
-**Full 追加**:
+Full 追加:
 - `high-point-checker`
 - `pacing-checker`
 
-## Step 4: 生成审查报告
+要求:
+- 所有子代理结果返回后,统一汇总 `issues`、`severity`、`overall_score`
 
-保存到:`审查报告/第{start}-{end}章审查报告.md`
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 4" --artifacts '{"review_completed":true}' || true
+```
 
-**报告结构(精简版)**:
-```markdown
-# 第 {start}-{end} 章质量审查报告
+### Step 6:生成审查报告与审查指标 JSON
 
-## 综合评分
-- 爽点密度 / 设定一致性 / 节奏控制 / 人物塑造 / 连贯性 / 追读力
-- 总评与等级
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 5" --step-name "生成审查报告" || true
+```
 
-## 修改优先级
-- 🔴 高优先级(必须修改)
-- 🟠 中优先级(建议修改)
-- 🟡 低优先级(可选优化)
+报告保存到:`审查报告/第{start}-{end}章审查报告.md`
 
-## 改进建议
-- 可执行的修复建议
-```
+报告结构:
+- 综合评分
+- 修改优先级
+- 改进建议
+
+审查指标 JSON 必须包含:
+- `start_chapter`
+- `end_chapter`
+- `overall_score`
+- `dimension_scores`
+- `severity_counts`
+- `critical_issues`
+- `report_file`
+- `notes`
 
-**审查指标 JSON(用于趋势统计)**:
-```json
-{
-  "start_chapter": {start},
-  "end_chapter": {end},
-  "overall_score": 48,
-  "dimension_scores": {
-    "爽点密度": 8,
-    "设定一致性": 7,
-    "节奏控制": 7,
-    "人物塑造": 8,
-    "连贯性": 9,
-    "追读力": 9
-  },
-  "severity_counts": {"critical": 1, "high": 2, "medium": 3, "low": 1},
-  "critical_issues": ["设定自相矛盾"],
-  "report_file": "审查报告/第{start}-{end}章审查报告.md",
-  "notes": ""
-}
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 5" --artifacts '{"report_generated":true}' || true
 ```
 
-注意:此处只生成审查指标 JSON;落库见 Step 5。
+### Step 7:写入 index.db 与 state.json
 
-## Step 5: 保存审查指标到 index.db(必做)
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 6" --step-name "写入审查指标" || true
+```
+
+保存审查指标:
 
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" index save-review-metrics --data '@review_metrics.json'
 ```
 
-## Step 6: 写回审查记录到 state.json(必做)
+写回审查记录:
 
-将审查报告记录写回 `state.json.review_checkpoints`,用于后续追踪与回溯(依赖 `update_state.py --add-review`):
 ```bash
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" update-state -- --add-review "{start}-{end}" "审查报告/第{start}-{end}章审查报告.md"
 ```
 
-## Step 7: 处理关键问题
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 6" --artifacts '{"review_metrics_saved":true}' || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 7" --step-name "写回审查记录" || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 7" --artifacts '{"review_checkpoint_saved":true}' || true
+```
+
+### Step 8:处理关键问题并收尾
 
-如发现 critical 问题(`severity_counts.critical > 0` 或 `critical_issues` 非空),**必须使用 AskUserQuestion** 询问用户:
-- A) 立即修复(推荐)
-- B) 仅保存报告,稍后处理
+```bash
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 8" --step-name "处理关键问题并收尾" || true
+```
+
+如存在 `critical` 问题,必须使用 `AskUserQuestion` 询问用户:
+- 立即修复
+- 仅保存报告,稍后处理
 
-若用户选择 A:
-- 输出“返工清单”(逐条 critical 问题 → 定位 → 最小修复动作 → 注意事项)
-- 如用户明确授权可直接修改正文文件,则用 `Edit` 对对应章节文件做最小修复,并建议重新运行一次 `/webnovel-review` 验证
+若用户选择立即修复
+- 输出返工清单
+- 在用户明确授权下做最小修改
 
-若用户选择 B
-- 不做正文修改,仅保留审查报告与指标记录,结束本次审查
+若用户选择稍后处理
+- 保留报告与指标记录,结束流程
 
-## Step 8: 收尾(完成任务)
+收尾:
 
 ```bash
-python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 8" --step-name "收尾" || true
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 8" --artifacts '{"ok":true}' || true
 python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-task --artifacts '{"ok":true}' || true
 ```
+
+## 成功标准
+
+1. 已解析真实书项目根目录。
+2. 已完成至少 Core 审查深度。
+3. 审查报告已生成。
+4. `review_metrics` 已写入 `index.db`。
+5. 审查记录已写回 `state.json`。
+6. 如存在关键问题,用户已明确选择处理策略。

+ 240 - 216
webnovel-writer/skills/webnovel-write/SKILL.md

@@ -1,127 +1,100 @@
 ---
 name: webnovel-write
-description: Writes webnovel chapters (default 2000-2500 words). Use when the user asks to write a chapter or runs /webnovel-write. Runs context, drafting, review, polish, and data extraction.
+description: 产出可发布章节,完整执行上下文、起草、审查、润色、数据回写与备份。
 allowed-tools: Read Write Edit Grep Bash Task
 ---
 
-# Chapter Writing (Structured Workflow)
+# Chapter Writing(结构化写作流程)
 
 ## 目标
 
-- 以稳定流程产出可发布章节:优先使用 `正文/第{NNNN}章-{title_safe}.md`,无标题时回退 `正文/第{NNNN}章.md`。
-- 默认章节字数目标:2000-2500(用户或大纲明确覆盖时从其约定)。
-- 保证审查、润色、数据回写完整闭环,避免“写完即丢上下文”。
-- 输出直接可被后续章节消费的结构化数据:`review_metrics`、`summaries`、`chapter_meta`。
+- 产出可发布章节,优先写入 `正文/第{NNNN}章-{title_safe}.md`,无标题时回退 `正文/第{NNNN}章.md`。
+- 默认目标字数 2000-2500;若用户或大纲另有要求,以用户和大纲为准。
+- 保证审查、润色、数据回写、长期记忆提取全部闭环。
+- 输出内容必须能被下一章直接消费。
+- 若章纲包含结构化节点,则正文必须围绕节点骨架展开。
 
 ## 执行原则
 
-1. 先校验输入完整性,再进入写作流程;缺关键输入时立即阻断
-2. 审查与数据回写是硬步骤,`--fast`/`--minimal` 只允许降级可选环节。
-3. 参考资料严格按步骤按需加载,不一次性灌入全部文档。
-4. Step 2B 与 Step 4 职责分离:2B 只做风格转译,4 只做问题修复与质控
-5. 任一步失败优先做最小回滚,不重跑全流程
+1. 先校验输入,再进入写作主链
+2. 审查与数据回写是硬步骤,`--fast` 与 `--minimal` 只允许裁剪可选环节。
+3. 参考资料按步骤按需加载,不一次性灌入全部文档。
+4. Step 4 只做问题修复与终检,不回写结构化数据
+5. 任一步失败优先最小补跑,不重跑整条链路
 
 ## 模式定义
 
-- `/webnovel-write`:Step 1 → 2A → 2B → 3 → 4 → 5 → 6
-- `/webnovel-write --fast`:Step 1 → 2A → 3 → 4 → 5 → 6(跳过 2B)
-- `/webnovel-write --minimal`:Step 1 → 2A → 3(仅3个基础审查)→ 4 → 5 → 6
+- `/webnovel-write`:Step 0.5 → Step 1 → Step 2A → Step 2B → Step 3 → Step 4 → Step 5 → Step 6
+- `/webnovel-write --fast`:Step 0.5 → Step 1 → Step 2A → Step 3 → Step 4 → Step 5 → Step 6
+- `/webnovel-write --minimal`:Step 0.5 → Step 1 → Step 2A → Step 2B → Step 3(仅核心 3 个审查)→ Step 4 → Step 5 → Step 6
 
-最小产物(所有模式)
-- `正文/第{NNNN}章-{title_safe}.md` 或 `正文/第{NNNN}章.md`
-- `index.db.review_metrics` 新纪录(含 `overall_score`)
+最小产物:
+- 章节正文文件
+- `index.db.review_metrics` 新记录
 - `.webnovel/summaries/ch{NNNN}.md`
-- `.webnovel/state.json` 的进度与 `chapter_meta` 更新
+- `.webnovel/state.json` 的进度与 `chapter_meta`
+- `.webnovel/memory_scratchpad.json` 的长期记忆事实
 
-### 流程硬约束(禁止事项)
+## 流程硬约束
 
-- **禁止并步**:不得将两个 Step 合并为一个动作执行(如同时做 2A 和 3)
-- **禁止跳步**:不得跳过未被模式定义标记为可跳过的 Step。
-- **禁止临时改名**:不得将 Step 的输出产物改写为非标准文件名或格式
-- **禁止自创模式**:`--fast` / `--minimal` 只允许按上方定义裁剪步骤,不允许自创混合模式、"半步"或"简化版"
-- **禁止自审替代**:Step 3 审查必须由 Task 子代理执行,主流程不得内联伪造审查结论
-- **禁止源码探测**:脚本调用方式以本文档与 data-agent 文档中的命令示例为准,命令失败时查日志定位问题,不去翻源码学习调用方式
+- 禁止并步:不得把两个 Step 合并执行
+- 禁止跳步:除模式定义明确允许外,不得跳过任何 Step。
+- 禁止改名:标准产物文件名和格式不得私自改写
+- 禁止伪造审查:Step 3 必须由 Task 子代理执行
+- 禁止源码探测:CLI 调用方式以本文档和 agent 文档为准,命令失败优先查日志
+- Workflow step-id 必须使用实现侧真实编号:`Step 0.5`、`Step 1`、`Step 2A`、`Step 2B`、`Step 3`、`Step 4`、`Step 5`、`Step 6`
 
-## 引用加载等级(strict, lazy)
+## 引用加载等级
 
-- L0:未进入对应步骤前,不加载任何参考文件
-- L1:每步仅加载该步“必读”文件。
-- L2:仅在触发条件满足时加载“条件必读/可选”文件
+- L0:未进入对应步骤前,不加载参考资料
+- L1:只加载当前步骤必读文件。
+- L2:仅在触发条件满足时加载条件参考
 
-路径约定:
-- `references/...` 相对当前 skill 目录。
-- `../../references/...` 指向全局共享参考。
-
-## References(逐文件引用清单)
-
-### 根目录
+## References
 
 - `references/step-3-review-gate.md`
-  - 用途:Step 3 审查调用模板、汇总格式、落库 JSON 规范。
-  - 触发:Step 3 必读。
+  - 用途:Step 3 审查调用模板与落库规范。
 - `references/step-5-debt-switch.md`
-  - 用途:Step 5 债务利息开关规则(默认关闭)。
-  - 触发:Step 5 必读。
+  - 用途:Step 5 债务利息开关规则。
 - `../../references/shared/core-constraints.md`
-  - 用途:Step 2A 写作硬约束(大纲即法律 / 设定即物理 / 发明需识别)。
-  - 触发:Step 2A 必读。
+  - 用途:Step 2A 起草硬约束。
 - `references/polish-guide.md`
-  - 用途:Step 4 问题修复、Anti-AI 与 No-Poison 规则。
-  - 触发:Step 4 必读。
+  - 用途:Step 4 润色与终检规则。
 - `references/writing/typesetting.md`
-  - 用途:Step 4 移动端阅读排版与发布前速查。
-  - 触发:Step 4 必读。
+  - 用途:Step 4 排版检查。
 - `references/style-adapter.md`
-  - 用途:Step 2B 风格转译规则,不改剧情事实。
-  - 触发:Step 2B 执行时必读(`--fast`/`--minimal` 跳过)。
+  - 用途:Step 2B 风格适配规则。
 - `references/style-variants.md`
-  - 用途:Step 1(内置 Contract)开头/钩子/节奏变体与重复风险控制。
-  - 触发:Step 1 当需要做差异化设计时加载。
+  - 用途:Step 1 差异化设计。
 - `../../references/reading-power-taxonomy.md`
-  - 用途:Step 1(内置 Contract)钩子、爽点、微兑现 taxonomy。
-  - 触发:Step 1 当需要追读力设计时加载。
+  - 用途:Step 1 追读力设计。
 - `../../references/genre-profiles.md`
-  - 用途:Step 1(内置 Contract)按题材配置节奏阈值与钩子偏好。
-  - 触发:Step 1 当 `state.project.genre` 已知时加载。
+  - 用途:Step 1 题材节奏与钩子偏好。
 - `references/writing/genre-hook-payoff-library.md`
-  - 用途:电竞/直播文/克苏鲁的钩子与微兑现快速库。
-  - 触发:Step 1 题材命中 `esports/livestream/cosmic-horror` 时必读。
-
-### writing(问题定向加读)
+  - 用途:Step 1 特定题材快速库。
 
+问题定向参考:
 - `references/writing/combat-scenes.md`
-  - 触发:战斗章或审查命中“战斗可读性/镜头混乱”。
 - `references/writing/dialogue-writing.md`
-  - 触发:审查命中 OOC、对话说明书化、对白辨识差。
 - `references/writing/emotion-psychology.md`
-  - 触发:情绪转折生硬、动机断层、共情弱。
 - `references/writing/scene-description.md`
-  - 触发:场景空泛、空间方位不清、切场突兀。
 - `references/writing/desire-description.md`
-  - 触发:主角目标弱、欲望驱动力不足。
 
-## 工具策略(按需)
+## 工具策略
 
-- `Read/Grep`:读取 `state.json`、大纲、章节正文与参考文件
-- `Bash`:运行 `extract_chapter_context.py`、`index_manager`、`workflow_manager`
-- `Task`:调用 `context-agent`、审查 subagent、`data-agent` 并行执行
+- `Read/Grep`:读取大纲、状态、正文与参考资料
+- `Bash`:运行 `webnovel.py` 与相关脚本
+- `Task`:调用 `context-agent`、审查器与 `data-agent`。
 
-## 交互流程
+## 执行流程
 
-### Step 0:预检与上下文最小加载
+### 准备阶段:预检与环境准备
 
-必须做:
-- 解析真实书项目根(book project_root):必须包含 `.webnovel/state.json`。
-- 校验核心输入:`大纲/总纲.md`、`${CLAUDE_PLUGIN_ROOT}/scripts/extract_chapter_context.py` 存在。
-- 规范化变量:
-  - `WORKSPACE_ROOT`:Claude Code 打开的工作区根目录(可能是书项目的父目录,例如 `D:\wk\xiaoshuo`)
-  - `PROJECT_ROOT`:真实书项目根目录(必须包含 `.webnovel/state.json`,例如 `D:\wk\xiaoshuo\凡人资本论`)
-  - `SKILL_ROOT`:skill 所在目录(固定 `${CLAUDE_PLUGIN_ROOT}/skills/webnovel-write`)
-  - `SCRIPTS_DIR`:脚本目录(固定 `${CLAUDE_PLUGIN_ROOT}/scripts`)
-  - `chapter_num`:当前章号(整数)
-  - `chapter_padded`:四位章号(如 `0007`)
+必须完成:
+- 解析真实书项目根,必须包含 `.webnovel/state.json`
+- 校验核心输入:`大纲/总纲.md`、`${CLAUDE_PLUGIN_ROOT}/scripts/extract_chapter_context.py`
+- 规范化变量:`WORKSPACE_ROOT`、`PROJECT_ROOT`、`SKILL_ROOT`、`SCRIPTS_DIR`、`chapter_num`、`chapter_padded`
 
-环境设置(bash 命令执行前):
 ```bash
 export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
 export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT:?CLAUDE_PLUGIN_ROOT is required}/scripts"
@@ -131,250 +104,301 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" p
 export PROJECT_ROOT="$(python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
 ```
 
-**硬门槛**:`preflight` 必须成功。它统一校验 `CLAUDE_PLUGIN_ROOT` 派生出的 `SKILL_ROOT` / `SCRIPTS_DIR`、`webnovel.py`、`extract_chapter_context.py` 和解析出的 `PROJECT_ROOT`。任一失败都立即阻断。
-
-输出:
-- “已就绪输入”与“缺失输入”清单;缺失则阻断并提示先补齐。
+硬门槛:
+- `preflight` 必须成功。
+- 任一核心输入缺失立即阻断。
 
-### Step 0.5:工作流断点记录(best-effort,不阻断)
+任务记录:
 
 ```bash
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-task --command webnovel-write --chapter {chapter_num} || true
-python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 1" --step-name "Context Agent" || true
-python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 1" --artifacts '{"ok":true}' || true
-python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-task --artifacts '{"ok":true}' || true
 ```
 
-要求:
-- `--step-id` 仅允许:`Step 1` / `Step 2A` / `Step 2B` / `Step 3` / `Step 4` / `Step 5` / `Step 6`。
-- 任何记录失败只记警告,不阻断写作。
-- 每个 Step 执行结束后,同样需要 `complete-step`(失败不阻断)。
+### Step 0.5:轻量节点预检
+
+目的:在不阻断流程的前提下,对章纲中的结构化节点做轻量一致性提醒。
+
+执行前记录:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 0.5" --step-name "节点预检" || true
+```
+
+规则:
+- 只在当前章详细大纲存在 `CBN/CEN` 时执行。
+- 只检查主角或 POV 角色相关节点。
+- 第一版仅检查:
+  - `CBN` 中地点是否与 `protagonist_state.location` 明显冲突
+  - `CBN/CEN` 中主角境界或能力要求是否与 `protagonist_state.power` 明显冲突
+- 检查结果仅作为警告注入给 `context-agent`,不得阻断流程。
+- 若无节点字段,直接跳过。
+
+警告示例:
+- `[NODE_WARNING] CBN 地点与当前状态不一致: 章纲=迦南学院入口, 实际=乌坦城`
+- `[NODE_WARNING] CBN 强度要求与当前境界不一致: 章纲=斗师级压制, 实际=斗者三星`
 
-### Step 1:Context Agent(内置 Context Contract,生成直写执行包)
+完成后记录:
 
-使用 Task 调用 `context-agent`,参数:
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 0.5" --artifacts '{"node_precheck":true}' || true
+```
+
+### Step 1:调用 Context Agent 生成执行包
+
+记录开始:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 1" --step-name "Context Agent" || true
+```
+
+使用 Task 调用 `context-agent`,输入:
 - `chapter`
 - `project_root`
 - `storage_path=.webnovel/`
 - `state_file=.webnovel/state.json`
+- 若存在 `NODE_WARNING`,一并传入
 
 硬要求:
-- 若 `state` 或大纲不可用,立即阻断并返回缺失项。
-- 输出必须同时包含:
-  - 7 板块任务书(目标/冲突/承接/角色/场景约束/伏笔/追读力);
-  - Context Contract 全字段(目标/阻力/代价/本章变化/未闭合问题/开头类型/情绪节奏/信息密度/过渡章判定/追读力设计);
-  - Step 2A 可直接消费的“写作执行包”(章节节拍、不可变事实清单、禁止事项、终检清单)。
-- 合同与任务书出现冲突时,以“大纲与设定约束更严格者”为准。
+- 输出必须包含任务书、Context Contract、Step 2A 直写提示词。
+- 执行包中必须纳入长期记忆约束与时间约束。
+- 若章纲提供结构化节点,执行包中必须包含“情节结构”板块与节拍映射。
 
-输出:
-- 单一“创作执行包”(任务书 + Context Contract + 直写提示词),供 Step 2A 直接消费,不再拆分独立 Step 1.5。
+记录完成:
 
-### Step 2A:正文起草
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 1" --artifacts '{"context_package":true}' || true
+```
+
+### Step 2A:起草正文
 
 执行前必须加载:
+
 ```bash
 cat "${SKILL_ROOT}/../../references/shared/core-constraints.md"
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 2A" --step-name "正文起草" || true
 ```
 
 硬要求:
-- 只输出纯正文到章节正文文件;若详细大纲已有章节名,优先使用 `正文/第{chapter_padded}章-{title_safe}.md`,否则回退为 `正文/第{chapter_padded}章.md`。
-- 默认按 2000-2500 字执行;若大纲为关键战斗章/高潮章/卷末章或用户明确指定,则按大纲/用户优先。
-- 禁止占位符正文(如 `[TODO]`、`[待补充]`)。
-- 保留承接关系:若上章有明确钩子,本章必须回应(可部分兑现)。
+- 只输出纯正文到章节文件。
+- 不得出现 `[TODO]`、`[待补充]` 等占位符。
+- 若上章存在明确钩子,本章必须回应。
+- 中文思维写作,不使用英文框架骨架驱动正文。
+- 若存在结构化节点:正文必须围绕 `CBN -> CPNs -> CEN` 展开,不得跳过必须节点。
 
-中文思维写作约束(硬规则):
-- **禁止"先英后中"**:不得先用英文工程化骨架(如 ABCDE 分段、Summary/Conclusion 框架)组织内容,再翻译成中文。
-- **中文叙事单元优先**:以"动作、反应、代价、情绪、场景、关系位移"为基本叙事单元,不使用英文结构标签驱动正文生成。
-- **禁止英文结论话术**:正文、审查说明、润色说明、变更摘要、最终报告中不得出现 Overall / PASS / FAIL / Summary / Conclusion 等英文结论标题。
-- **英文仅限机器标识**:CLI flag(`--fast`)、checker id(`consistency-checker`)、DB 字段名(`anti_ai_force_check`)、JSON 键名等不可改的接口名保持英文,其余一律使用简体中文。
+完成后记录:
 
-输出:
-- 章节草稿(可进入 Step 2B 或 Step 3)。
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 2A" --artifacts '{"chapter_draft":true}' || true
+```
 
-### Step 2B:风格适配(`--fast` / `--minimal` 跳过)
+### Step 2B:风格适配(`--fast` 跳过)
 
 执行前加载:
+
 ```bash
 cat "${SKILL_ROOT}/references/style-adapter.md"
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 2B" --step-name "风格适配" || true
 ```
 
 硬要求:
-- 只做表达层转译,不改剧情事实、事件顺序、角色行为结果、设定规则。
-- 对“模板腔、说明腔、机械腔”做定向改写,为 Step 4 留出问题修复空间。
+- 只改表达,不改事实、事件顺序、人物行为结果、设定规则。
+- 重点消除模板腔、说明腔、机械腔。
+- 若存在结构化节点,不得因风格改写破坏节点顺序和收束方向。
+
+完成后记录:
 
-输出:
-- 风格化正文(覆盖原章节文件)。
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 2B" --artifacts '{"style_adapted":true}' || true
+```
 
-### Step 3:审查(auto 路由,必须由 Task 子代理执行)
+### Step 3:执行审查并落库
 
 执行前加载:
+
 ```bash
 cat "${SKILL_ROOT}/references/step-3-review-gate.md"
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 3" --step-name "审查" || true
 ```
 
 调用约束:
-- 必须用 `Task` 调用审查 subagent,禁止主流程伪造审查结论。
-- 可并行发起审查,统一汇总 `issues/severity/overall_score`。
-- 默认使用 `auto` 路由:根据“本章执行合同 + 正文信号 + 大纲标签”动态选择审查器。
+- 必须用 Task 调用审查子代理。
+- 默认使用 `auto` 路由动态选择检查器。
 
-核心审查器(始终执行)
+核心审查器:
 - `consistency-checker`
 - `continuity-checker`
 - `ooc-checker`
 
-条件审查器(`auto` 命中时执行)
+条件审查器:
 - `reader-pull-checker`
 - `high-point-checker`
 - `pacing-checker`
 
-模式说明:
-- 标准/`--fast`:核心 3 个 + auto 命中的条件审查器
-- `--minimal`:只跑核心 3 个(忽略条件审查器)
+模式规则:
+- 标准模式与 `--fast`:核心 3 个 + auto 命中的条件审查器
+- `--minimal`:只跑核心 3 个
+
+Step 3 中间产物约定:
+- checker 原始结果:`${PROJECT_ROOT}/.webnovel/tmp/review_results.json`
+- 聚合结果:`${PROJECT_ROOT}/.webnovel/tmp/review_aggregated.json`
+- 落库指标:`${PROJECT_ROOT}/.webnovel/tmp/review_metrics.json`
+
+标准文件流:
 
-审查指标落库(必做):
 ```bash
-python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" index save-review-metrics --data "@${PROJECT_ROOT}/.webnovel/tmp/review_metrics.json"
-```
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" review-pipeline   --chapter {chapter_num}   --review-results "${PROJECT_ROOT}/.webnovel/tmp/review_results.json"   --aggregated-out "${PROJECT_ROOT}/.webnovel/tmp/review_aggregated.json"   --review-metrics-out "${PROJECT_ROOT}/.webnovel/tmp/review_metrics.json"   --report-file "审查报告/第{chapter_num}章审查报告.md"
 
-review_metrics 字段约束(当前工作流约定只传以下字段):
-```json
-{
-  "start_chapter": 100,
-  "end_chapter": 100,
-  "overall_score": 85.0,
-  "dimension_scores": {"爽点密度": 8.5, "设定一致性": 8.0, "节奏控制": 7.8, "人物塑造": 8.2, "连贯性": 9.0, "追读力": 8.7},
-  "severity_counts": {"critical": 0, "high": 1, "medium": 2, "low": 0},
-  "critical_issues": ["问题描述"],
-  "report_file": "审查报告/第100-100章审查报告.md",
-  "notes": "单个字符串;selected_checkers / timeline_gate / anti_ai_force_check 等扩展信息压成单行文本写入此字段"
-}
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" index save-review-metrics --data "@${PROJECT_ROOT}/.webnovel/tmp/review_metrics.json"
 ```
-- `notes` 在当前执行契约中必须是单个字符串,不得传入对象或数组。
-- 当前工作流不额外传入其它顶层字段;脚本侧未在此处做新增硬校验。
 
 硬要求:
-- `--minimal` 也必须产出 `overall_score`。
-- 未落库 `review_metrics` 不得进入 Step 5。
+- `review_results.json` 必须保留各 checker 原始结构化结果。
+- `review_aggregated.json` 必须包含 `overall_score`、`issues` 与 `timeline_gate`。
+- `review_metrics.json` 的 `notes` 必须是单个字符串。
+- 若 `review_aggregated.json.timeline_gate.blocked=true`,不得进入 Step 4/5。
+- Step 4 必须直接消费 `review_results.json` 与 `review_aggregated.json`。
 
-### Step 4:润色(问题修复优先)
+完成后记录:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 3" --artifacts '{"review_completed":true}' || true
+```
+
+### Step 4:润色与全文终检
 
 执行前必须加载:
+
 ```bash
 cat "${SKILL_ROOT}/references/polish-guide.md"
 cat "${SKILL_ROOT}/references/writing/typesetting.md"
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 4" --step-name "润色" || true
 ```
 
 执行顺序:
-1. 修复 `critical`(必须)
-2. 修复 `high`(不能修复则记录 deviation)
-3. 处理 `medium/low`(按收益择优)
-4. 执行 Anti-AI 与 No-Poison 全文终检(必须输出 `anti_ai_force_check: pass/fail`)
+1. 修复已知严重问题
+2. 统一段落、节奏、排版
+3. 执行 Anti-AI 与 No-Poison 全文终检
 
-输出:
-- 润色后正文(覆盖章节文件)
-- 变更摘要(至少含:修复项、保留项、deviation、`anti_ai_force_check`)
+硬要求:
+- 必须输出 `anti_ai_force_check=pass/fail`
+- `fail` 时不得进入 Step 5
+- 有节点时,不得在润色中删除必须节点对应的情节落点
 
-### Step 5:Data Agent(状态与索引回写)
+完成后记录:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 4" --artifacts '{"anti_ai_force_check":"pass"}' || true
+```
+
+### Step 5:调用 Data Agent 回写结构化数据
+
+执行前记录:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 5" --step-name "Data Agent" || true
+```
 
 使用 Task 调用 `data-agent`,参数:
 - `chapter`
-- `chapter_file` 必须传入实际章节文件路径;若详细大纲已有章节名,优先传 `正文/第{chapter_padded}章-{title_safe}.md`,否则传 `正文/第{chapter_padded}章.md`
+- `chapter_file`
 - `review_score=Step 3 overall_score`
 - `project_root`
 - `storage_path=.webnovel/`
 - `state_file=.webnovel/state.json`
 
-Data Agent 默认子步骤(全部执行):
-- A. 加载上下文
-- B. AI 实体提取
-- C. 实体消歧
-- D. 写入 state/index
-- E. 写入章节摘要
-- F. AI 场景切片
-- G. RAG 向量索引(`rag index-chapter --scenes ...`)
-- H. 风格样本评估(`style extract --scenes ...`,仅 `review_score >= 80` 时)
-- I. 债务利息(默认跳过)
-
-`--scenes` 来源优先级(G/H 步骤共用):
-1. 优先从 `index.db` 的 scenes 记录获取(Step F 写入的结果)
-2. 其次按 `start_line` / `end_line` 从正文切片构造
-3. 最后允许单场景退化(整章作为一个 scene)
-
-Step 5 失败隔离规则:
-- 若 G/H 失败原因是 `--scenes` 缺失、scene 为空、scene JSON 格式错误:只补跑 G/H 子步骤,不回滚或重跑 Step 1-4。
-- 若 A-E 失败(state/index/summary 写入失败):仅重跑 Step 5,不回滚已通过的 Step 1-4。
-- 禁止因 RAG/style 子步骤失败而重跑整个写作链。
-
-执行后检查(最小白名单):
+Data Agent 默认子步骤全部执行:
+- 加载上下文
+- 实体提取与消歧
+- 写入 state/index
+- 写入章节摘要
+- 提取长期记忆 `memory_facts`
+- 场景切片
+- RAG 向量索引
+- 风格样本评估(仅 `review_score >= 80`)
+- 债务利息(默认关闭)
+
+失败隔离规则:
+- state/index/summary/memory 写入失败:只重跑 Step 5
+- `--scenes` 缺失导致的向量或风格样本失败:只补跑对应子步骤
+- 禁止因 Step 5 子步骤失败而回滚 Step 1-4
+
+执行后最小检查白名单:
 - `.webnovel/state.json`
 - `.webnovel/index.db`
 - `.webnovel/summaries/ch{chapter_padded}.md`
-- `.webnovel/observability/data_agent_timing.jsonl`(观测日志)
+- `.webnovel/memory_scratchpad.json`
+- `.webnovel/observability/data_agent_timing.jsonl`
 
 性能要求:
-- 读取 timing 日志最近一条;
-- `TOTAL > 30000ms` 时,输出最慢 2-3 个环节与原因说明
+- 读取最新 timing 记录
+- `TOTAL > 30000ms` 时,输出最慢 2-3 个环节与原因说明
 
-观测日志说明:
-- `call_trace.jsonl`:外层流程调用链(agent 启动、排队、环境探测等系统开销)。
-- `data_agent_timing.jsonl`:Data Agent 内部各子步骤耗时。
-- 当外层总耗时远大于内层 timing 之和时,默认先归因为 agent 启动与环境探测开销,不误判为正文或数据处理慢。
+完成后记录:
 
-债务利息:
-- 默认关闭,仅在用户明确要求或开启追踪时执行(见 `step-5-debt-switch.md`)。
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 5" --artifacts '{"state_json_modified":true,"entities_appeared":true}' || true
+```
 
-### Step 6:Git 备份(可失败但需说明)
+### Step 6:Git 备份
+
+执行前记录:
 
 ```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 6" --step-name "Git 备份" || true
 git add .
 git -c i18n.commitEncoding=UTF-8 commit -m "第{chapter_num}章: {title}"
 ```
 
 规则:
-- 提交时机:验证、回写、清理全部完成后最后执行。
-- 提交信息默认中文,格式:`第{chapter_num}章: {title}`。
-- 若 commit 失败,必须给出失败原因与未提交文件范围。
+- 所有验证和回写完成后最后执行。
+- commit 失败时,必须说明失败原因与未提交文件范围。
 
-## 充分性闸门(必须通过)
+完成后记录:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 6" --artifacts '{"git_status":{"committed":true}}' || true
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-task --artifacts '{"ok":true}' || true
+```
+
+## 充分性闸门
 
 未满足以下条件前,不得结束流程:
 
-1. 章节正文文件存在且非空:`正文/第{chapter_padded}章-{title_safe}.md` 或 `正文/第{chapter_padded}章.md`
-2. Step 3 已产出 `overall_score` 且 `review_metrics` 成功落库
-3. Step 4 已处理全部 `critical`,`high` 未修项有 deviation 记录
-4. Step 4 的 `anti_ai_force_check=pass`(基于全文检查;fail 时不得进入 Step 5)
-5. Step 5 已回写 `state.json`、`index.db`、`summaries/ch{chapter_padded}.md`
-6. 若开启性能观测,已读取最新 timing 记录并输出结论
+1. 章节正文文件存在且非空。
+2. Step 3 已产出 `${PROJECT_ROOT}/.webnovel/tmp/review_aggregated.json`,其中包含 `overall_score`。
+3. Step 3 的 `review_metrics` 已落库。
+4. 若存在 `timeline_gate.blocked=true`,流程必须停在 Step 3。
+5. Step 4 的 `anti_ai_force_check=pass`。
+6. Step 5 已更新 `state.json`、`index.db`、`summaries/ch{chapter_padded}.md`。
+7. Step 5 已写入 `.webnovel/memory_scratchpad.json`。
+8. 若启用观测,已读取最新 timing 记录并给出结论。
 
 ## 验证与交付
 
-执行检查:
-
 ```bash
 test -f "${PROJECT_ROOT}/.webnovel/state.json"
 test -f "${PROJECT_ROOT}/正文/第${chapter_padded}章.md"
 test -f "${PROJECT_ROOT}/.webnovel/summaries/ch${chapter_padded}.md"
+test -f "${PROJECT_ROOT}/.webnovel/memory_scratchpad.json"
 python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" index get-recent-review-metrics --limit 1
 tail -n 1 "${PROJECT_ROOT}/.webnovel/observability/data_agent_timing.jsonl" || true
 ```
 
 成功标准:
-- 章节文件、摘要文件、状态文件齐全且内容可读。
+- 章节文件、摘要文件、状态文件、长期记忆暂存文件齐全且内容可读。
 - 审查分数可追溯,`overall_score` 与 Step 5 输入一致。
-- 润色后未破坏大纲与设定约束。
+- 润色后未破坏大纲、设定与长期记忆约束。
 
-## 失败处理(最小回滚)
+## 失败处理
 
 触发条件:
-- 章节文件缺失或空文件;
-- 审查结果未落库;
-- Data Agent 关键产物缺失;
-- 润色引入设定冲突。
-
-恢复流程:
-1. 仅重跑失败步骤,不回滚已通过步骤。
-2. 常见最小修复:
-   - 审查缺失:只重跑 Step 3 并落库;
-   - 润色失真:恢复 Step 2A 输出并重做 Step 4;
-   - 摘要/状态缺失:只重跑 Step 5;
-3. 重新执行“验证与交付”全部检查,通过后结束。
+- 章节文件缺失或为空
+- 审查结果未落库
+- Data Agent 关键产物缺失
+- 润色引入设定冲突
+
+恢复规则:
+1. 只补跑失败步骤,不回滚已通过步骤。
+2. 审查缺失:只重跑 Step 3。
+3. 摘要、状态、长期记忆缺失:只重跑 Step 5。
+4. 润色失真:回到 Step 4 修复后重新执行 Step 5。

+ 1 - 1
webnovel-writer/skills/webnovel-write/references/step-3-review-gate.md

@@ -25,7 +25,7 @@
 ## Auto 路由判定信号
 
 输入信号来源:
-1. Step 1.5 合同(是否过渡章、追读力设计、核心冲突)。
+1. Step 1 内置合同(历史文件名仍为 `step-1.5-contract.md`,内容包括是否过渡章、追读力设计、核心冲突)。
 2. 本章正文(战斗/反转/高光/章末未闭合问题等信号)。
 3. 大纲标签(关键章/高潮章/卷末章/转场章)。
 4. 最近章节节奏(连续主线、情感线断档、世界观线断档)。

+ 8 - 6
webnovel-writer/skills/webnovel-write/references/workflow-details.md

@@ -1,13 +1,15 @@
-# Workflow Details (Deprecated)
+# Workflow Details(Deprecated)
 
-本文件已拆分为按步骤单文件,主流程不再直接引用
+本文件已废弃,主流程请以 `SKILL.md` 为准,不再直接引用本文件
 
-请改为按 Step 加载以下文件
+若需要按步骤查参考资料,请使用以下映射
 
-- Step 1.5:`references/step-1.5-contract.md`
+- Step 1:`references/step-1.5-contract.md`
+- Step 2B:`references/style-adapter.md`
 - Step 3:`references/step-3-review-gate.md`
+- Step 4:`references/polish-guide.md`
+- Step 4:`references/writing/typesetting.md`
 - Step 5:`references/step-5-debt-switch.md`
 
 说明:
-- Step 2B 请读取 `references/style-adapter.md`
-- Step 4 请读取 `references/polish-guide.md` 与 `references/writing/typesetting.md`
+- `step-1.5-contract.md` 是历史文件名,当前用于 Step 1 的内置合同,不单独作为 workflow step-id 使用。

+ 1 - 1
webnovel-writer/skills/webnovel-write/references/writing/genre-hook-payoff-library.md

@@ -1,6 +1,6 @@
 # 新题材快速库:章末钩子 + 微兑现(电竞 / 直播文 / 克苏鲁)
 
-用于 `webnovel-write` Step 1.5:快速选一条章末钩子 + 一条微兑现,避免同质化。
+用于 `webnovel-write` Step 1 内置合同:快速选一条章末钩子 + 一条微兑现,避免同质化。
 
 ---