Просмотр исходного кода

chore(v5.4): Agent 文档和模块更新

- context-agent: 添加追读力设计输出说明
- data-agent: 添加 invalid_facts 处理流程
- CLAUDE.md: 更新版本说明
- 其他模块: 代码清理和兼容性修复

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
lingfengQAQ 4 месяцев назад
Родитель
Сommit
e9a5d61b86

+ 17 - 0
.claude/agents/context-agent.md

@@ -69,6 +69,9 @@ tools: Read, Grep, Bash
 - `state.json`: 进度、主角状态、strand_tracker、chapter_meta、project.genre
 - `index.db`: 实体/别名/关系/状态变化/override_contracts/chase_debt/chapter_reading_power
 - `.webnovel/summaries/ch{NNNN}.md`: 章节摘要(含钩子/结束状态)
+- `.webnovel/context_snapshots/`: 上下文快照(优先复用)
+- `.webnovel/preferences.json`: 用户偏好(阶段3)
+- `.webnovel/project_memory.json`: 项目记忆(阶段3)
 - `大纲/`: 本章大纲 + 卷概述
 - `设定集/`: 世界观/力量体系/角色卡
 - `.claude/references/`: Taxonomy + Genre Profiles
@@ -77,6 +80,14 @@ tools: Read, Grep, Bash
 
 ## 执行流程
 
+### Step 0: ContextManager 快照优先
+```bash
+python -m data_modules.context_manager --chapter {NNNN} --project-root "."
+```
+- 若存在兼容快照,直接读取
+- 版本不兼容时自动重建并保存
+- 过滤 confirmed 的 invalid_facts,pending 标记为提示
+
 ### Step 1: 读取题材Profile
 ```bash
 # 从 state.json 获取题材
@@ -122,6 +133,12 @@ python -m data_modules.index_manager get-debt-summary --project-root "."
 python -m data_modules.index_manager get-pending-overrides --before-chapter {current+3} --project-root "."
 ```
 
+### Step 6: 来源标注(人读)
+- 对所有事实性引用追加来源标注,例如:
+  - `【来源: summaries/ch0100.md】`
+  - `【来源: 正文/第0100章.md#scene_2】`
+- 若为推断信息,明确标注“推断”。
+
 ### Step 6: 查询实体与关系(index.db)
 ```bash
 python -m data_modules.index_manager get-core-entities --project-root "."

+ 12 - 1
.claude/agents/data-agent.md

@@ -152,9 +152,20 @@ hook_strength: "strong"
 ### Step G: 向量嵌入
 
 ```bash
-python -m data_modules.rag_adapter index-chapter --chapter 100 --scenes '[...]' --project-root "."
+python -m data_modules.rag_adapter index-chapter \
+  --chapter 100 \
+  --scenes '[...]' \
+  --summary "本章摘要文本" \
+  --project-root "."
 ```
 
+**父子索引规则 (v1.2)**:
+- 父块: `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: `正文/第0100章.md#scene_{scene_index}`
+
 ### Step H: 风格样本评估
 
 ```python

+ 28 - 0
.claude/references/preferences-schema.md

@@ -0,0 +1,28 @@
+# preferences.json 设计
+
+用于保存用户偏好与写作约束(可由 /webnovel-init 或用户手动编辑)。
+
+## 示例
+
+```json
+{
+  "tone": "热血",
+  "pacing": {
+    "chapter_words": 2500,
+    "cliffhanger": true
+  },
+  "style": {
+    "dialogue_ratio": 0.35,
+    "narration_ratio": 0.65
+  },
+  "avoid": ["过度旁白", "重复台词"],
+  "focus": ["主角成长", "战斗描写"]
+}
+```
+
+## 字段说明
+- tone: 全局情绪基调
+- pacing: 节奏偏好
+- style: 叙事/对话比例
+- avoid: 禁忌清单
+- focus: 必须强调的方向

+ 25 - 0
.claude/references/project-memory-schema.md

@@ -0,0 +1,25 @@
+# project_memory.json 设计
+
+用于保存长期可复用的写作模式,由 `/webnovel-learn` 写入。
+
+## 示例
+
+```json
+{
+  "patterns": [
+    {
+      "pattern_type": "hook",
+      "description": "危机钩设计:悬念拉满",
+      "source_chapter": 100,
+      "learned_at": "2026-02-02T12:00:00Z"
+    }
+  ]
+}
+```
+
+## 字段说明
+- patterns: 已验证的写作模式列表
+  - pattern_type: hook / pacing / dialogue / payoff / emotion
+  - description: 可复用描述
+  - source_chapter: 来源章节
+  - learned_at: 记录时间

+ 6 - 0
.claude/scripts/data_modules/__init__.py

@@ -18,6 +18,9 @@ from .entity_linker import EntityLinker, DisambiguationResult
 from .state_manager import StateManager, EntityState, Relationship, StateChange
 from .index_manager import IndexManager, ChapterMeta, SceneMeta
 from .rag_adapter import RAGAdapter, SearchResult
+from .context_manager import ContextManager
+from .snapshot_manager import SnapshotManager
+from .query_router import QueryRouter
 from .style_sampler import StyleSampler, StyleSample, SceneType
 
 __all__ = [
@@ -43,6 +46,9 @@ __all__ = [
     # RAG Adapter
     "RAGAdapter",
     "SearchResult",
+    "ContextManager",
+    "SnapshotManager",
+    "QueryRouter",
     # Style Sampler
     "StyleSampler",
     "StyleSample",

+ 28 - 17
.claude/scripts/data_modules/entity_linker.py

@@ -179,6 +179,8 @@ class EntityLinker:
 
 def main():
     import argparse
+    from .cli_output import print_success, print_error
+    from .index_manager import IndexManager
 
     parser = argparse.ArgumentParser(description="Entity Linker CLI (v5.1 SQLite)")
     parser.add_argument("--project-root", type=str, help="项目根目录")
@@ -214,41 +216,50 @@ def main():
         config = DataModulesConfig.from_project_root(args.project_root)
 
     linker = EntityLinker(config)
+    logger = IndexManager(config)
+    tool_name = f"entity_linker:{args.command or 'unknown'}"
+
+    def emit_success(data=None, message: str = "ok"):
+        print_success(data, message=message)
+        try:
+            logger.log_tool_call(tool_name, True)
+        except Exception:
+            pass
+
+    def emit_error(code: str, message: str, suggestion: str | None = None):
+        print_error(code, message, suggestion=suggestion)
+        try:
+            logger.log_tool_call(tool_name, False, error_code=code, error_message=message)
+        except Exception:
+            pass
 
     if args.command == "register-alias":
         entity_type = getattr(args, "type", "角色")
         success = linker.register_alias(args.entity, args.alias, entity_type)
         if success:
-            print(f"✓ 已注册: {args.alias} → {args.entity} (类型: {entity_type})")
+            emit_success({"entity": args.entity, "alias": args.alias, "type": entity_type}, message="alias_registered")
         else:
-            print(f"✗ 注册失败或已存在")
+            emit_error("ALIAS_EXISTS", "注册失败或已存在")
 
     elif args.command == "lookup":
         entity_type = getattr(args, "type", None)
         entity_id = linker.lookup_alias(args.mention, entity_type)
         if entity_id:
-            print(f"{args.mention} → {entity_id}")
+            emit_success({"mention": args.mention, "entity": entity_id}, message="lookup")
         else:
-            print(f"未找到: {args.mention}")
+            emit_error("NOT_FOUND", f"未找到别名: {args.mention}")
 
     elif args.command == "lookup-all":
-        entries = linker.lookup_alias_all(args.mention)
-        if entries:
-            print(f"{args.mention} 的所有匹配:")
-            for entry in entries:
-                print(f"  - {entry.get('id')} (类型: {entry.get('type')})")
-        else:
-            print(f"未找到: {args.mention}")
+        matches = linker.lookup_alias_all(args.mention)
+        emit_success(matches, message="lookup_all")
 
     elif args.command == "list-aliases":
         entity_type = getattr(args, "type", None)
         aliases = linker.get_all_aliases(args.entity, entity_type)
-        if aliases:
-            print(f"{args.entity} 的别名:")
-            for alias in aliases:
-                print(f"  - {alias}")
-        else:
-            print(f"未找到 {args.entity} 的别名")
+        emit_success(aliases, message="aliases")
+
+    else:
+        emit_error("UNKNOWN_COMMAND", "未指定有效命令", suggestion="请查看 --help")
 
 
 if __name__ == "__main__":

+ 32 - 15
.claude/scripts/data_modules/migrate_state_to_sqlite.py

@@ -324,6 +324,8 @@ def _slim_relationships(relationships: Dict) -> Dict:
 
 def main():
     import argparse
+    from .cli_output import print_success, print_error
+    from .index_manager import IndexManager
 
     parser = argparse.ArgumentParser(description="迁移 state.json 到 SQLite (v5.1)")
     parser.add_argument("--project-root", type=str, required=True, help="项目根目录")
@@ -336,22 +338,37 @@ def main():
 
     config = DataModulesConfig.from_project_root(args.project_root)
     backup = not args.no_backup
+    logger = IndexManager(config)
+    tool_name = "migrate_state_to_sqlite"
+
+    try:
+        stats = migrate_state_to_sqlite(
+            config=config,
+            dry_run=args.dry_run,
+            backup=backup,
+            verbose=False,
+        )
+    except Exception as exc:
+        print_error("MIGRATE_FAILED", str(exc), suggestion="检查 state.json 与 index.db 权限")
+        try:
+            logger.log_tool_call(tool_name, False, error_code="MIGRATE_FAILED", error_message=str(exc))
+        except Exception:
+            pass
+        raise SystemExit(1)
 
-    print(f"🚀 开始迁移 state.json → SQLite")
-    print(f"   项目: {config.project_root}")
-    print(f"   state.json: {config.state_file}")
-    print(f"   index.db: {config.index_db}")
-    print()
-
-    stats = migrate_state_to_sqlite(
-        config=config,
-        dry_run=args.dry_run,
-        backup=backup,
-        verbose=not args.quiet
-    )
-
-    if stats["errors"] > 0:
-        exit(1)
+    if stats.get("errors", 0) > 0:
+        print_error("MIGRATE_ERRORS", "迁移出现错误", details=stats)
+        try:
+            logger.log_tool_call(tool_name, False, error_code="MIGRATE_ERRORS", error_message="迁移出现错误")
+        except Exception:
+            pass
+        raise SystemExit(1)
+
+    print_success({"project": str(config.project_root), **stats}, message="migrated")
+    try:
+        logger.log_tool_call(tool_name, True)
+    except Exception:
+        pass
 
 
 if __name__ == "__main__":

+ 29 - 9
.claude/scripts/data_modules/sql_state_manager.py

@@ -470,6 +470,8 @@ class SQLStateManager:
 
 def main():
     import argparse
+    from .cli_output import print_success, print_error
+    from .index_manager import IndexManager
 
     parser = argparse.ArgumentParser(description="SQL State Manager CLI (v5.1)")
     parser.add_argument("--project-root", type=str, help="项目根目录")
@@ -505,29 +507,45 @@ def main():
         config = DataModulesConfig.from_project_root(args.project_root)
 
     manager = SQLStateManager(config)
+    logger = IndexManager(config)
+    tool_name = f"sql_state_manager:{args.command or 'unknown'}"
+
+    def emit_success(data=None, message: str = "ok"):
+        print_success(data, message=message)
+        try:
+            logger.log_tool_call(tool_name, True)
+        except Exception:
+            pass
+
+    def emit_error(code: str, message: str, suggestion: str | None = None):
+        print_error(code, message, suggestion=suggestion)
+        try:
+            logger.log_tool_call(tool_name, False, error_code=code, error_message=message)
+        except Exception:
+            pass
 
     if args.command == "stats":
         stats = manager.get_stats()
-        print(json.dumps(stats, ensure_ascii=False, indent=2))
+        emit_success(stats, message="stats")
 
     elif args.command == "get-protagonist":
         protagonist = manager.get_protagonist()
         if protagonist:
-            print(json.dumps(protagonist, ensure_ascii=False, indent=2))
+            emit_success(protagonist, message="protagonist")
         else:
-            print("未设置主角")
+            emit_error("NOT_FOUND", "未设置主角")
 
     elif args.command == "get-core-entities":
         entities = manager.get_core_entities()
-        print(json.dumps(entities, ensure_ascii=False, indent=2))
+        emit_success(entities, message="core_entities")
 
     elif args.command == "export-entities-v3":
         data = manager.export_to_entities_v3_format()
-        print(json.dumps(data, ensure_ascii=False, indent=2))
+        emit_success(data, message="entities_v3")
 
     elif args.command == "export-alias-index":
         data = manager.export_to_alias_index_format()
-        print(json.dumps(data, ensure_ascii=False, indent=2))
+        emit_success(data, message="alias_index")
 
     elif args.command == "process-chapter":
         data = json.loads(args.data)
@@ -536,10 +554,12 @@ def main():
             entities_appeared=data.get("entities_appeared", []),
             entities_new=data.get("entities_new", []),
             state_changes=data.get("state_changes", []),
-            relationships_new=data.get("relationships_new", [])
+            relationships_new=data.get("relationships_new", []),
         )
-        print(f"✓ 已处理第 {args.chapter} 章")
-        print(json.dumps(stats, ensure_ascii=False, indent=2))
+        emit_success(stats, message="chapter_processed")
+
+    else:
+        emit_error("UNKNOWN_COMMAND", "未指定有效命令", suggestion="请查看 --help")
 
 
 if __name__ == "__main__":

+ 51 - 14
.claude/scripts/data_modules/state_manager.py

@@ -1163,18 +1163,22 @@ class StateManager:
 
 def main():
     import argparse
+    from pydantic import ValidationError
+    from .cli_output import print_success, print_error
+    from .schemas import validate_data_agent_output, format_validation_error, normalize_data_agent_output
+    from .index_manager import IndexManager
 
-    parser = argparse.ArgumentParser(description="State Manager CLI")
+    parser = argparse.ArgumentParser(description="State Manager CLI (v5.2)")
     parser.add_argument("--project-root", type=str, help="项目根目录")
 
     subparsers = parser.add_subparsers(dest="command")
 
-    # 取进度
+    # 取进度
     subparsers.add_parser("get-progress")
 
     # 获取实体
     get_entity_parser = subparsers.add_parser("get-entity")
-    get_entity_parser.add_argument("--id", required=True, help="实体ID")
+    get_entity_parser.add_argument("--id", required=True)
 
     # 列出实体
     list_parser = subparsers.add_parser("list-entities")
@@ -1195,16 +1199,32 @@ def main():
         config = DataModulesConfig.from_project_root(args.project_root)
 
     manager = StateManager(config)
+    logger = IndexManager(config)
+    tool_name = f"state_manager:{args.command or 'unknown'}"
+
+    def emit_success(data=None, message: str = "ok"):
+        print_success(data, message=message)
+        try:
+            logger.log_tool_call(tool_name, True)
+        except Exception:
+            pass
+
+    def emit_error(code: str, message: str, suggestion: str | None = None):
+        print_error(code, message, suggestion=suggestion)
+        try:
+            logger.log_tool_call(tool_name, False, error_code=code, error_message=message)
+        except Exception:
+            pass
 
     if args.command == "get-progress":
-        print(json.dumps(manager._state.get("progress", {}), ensure_ascii=False, indent=2))
+        emit_success(manager._state.get("progress", {}), message="progress")
 
     elif args.command == "get-entity":
         entity = manager.get_entity(args.id)
         if entity:
-            print(json.dumps(entity, ensure_ascii=False, indent=2))
+            emit_success(entity, message="entity")
         else:
-            print(f"未找到实体: {args.id}")
+            emit_error("NOT_FOUND", f"未找到实体: {args.id}")
 
     elif args.command == "list-entities":
         if args.type:
@@ -1214,19 +1234,36 @@ def main():
         else:
             entities = manager.get_all_entities()
 
-        for eid, e in entities.items():
-            print(f"{eid}: {e.get('name')} ({e.get('type')}/{e.get('tier')})")
+        payload = [{"id": eid, **e} for eid, e in entities.items()]
+        emit_success(payload, message="entities")
 
     elif args.command == "process-chapter":
         data = json.loads(args.data)
-        warnings = manager.process_chapter_result(args.chapter, data)
+        validated = None
+        last_exc = None
+        for _ in range(3):
+            try:
+                validated = validate_data_agent_output(data)
+                break
+            except ValidationError as exc:
+                last_exc = exc
+                data = normalize_data_agent_output(data)
+        if validated is None:
+            err = format_validation_error(last_exc) if last_exc else {
+                "code": "SCHEMA_VALIDATION_FAILED",
+                "message": "数据结构校验失败",
+                "details": {"errors": []},
+                "suggestion": "请检查 data-agent 输出字段是否完整且类型正确",
+            }
+            emit_error(err["code"], err["message"], suggestion=err.get("suggestion"))
+            return
+
+        warnings = manager.process_chapter_result(args.chapter, validated.model_dump(by_alias=True))
         manager.save_state()
+        emit_success({"chapter": args.chapter, "warnings": warnings}, message="chapter_processed")
 
-        print(f"✓ 已处理第 {args.chapter} 章")
-        if warnings:
-            print("警告:")
-            for w in warnings:
-                print(f"  - {w}")
+    else:
+        emit_error("UNKNOWN_COMMAND", "未指定有效命令", suggestion="请查看 --help")
 
 
 if __name__ == "__main__":

+ 29 - 12
.claude/scripts/data_modules/style_sampler.py

@@ -308,6 +308,8 @@ class StyleSampler:
 
 def main():
     import argparse
+    from .cli_output import print_success, print_error
+    from .index_manager import IndexManager
 
     parser = argparse.ArgumentParser(description="Style Sampler CLI")
     parser.add_argument("--project-root", type=str, help="项目根目录")
@@ -342,20 +344,33 @@ def main():
         config = DataModulesConfig.from_project_root(args.project_root)
 
     sampler = StyleSampler(config)
+    logger = IndexManager(config)
+    tool_name = f"style_sampler:{args.command or 'unknown'}"
+
+    def emit_success(data=None, message: str = "ok"):
+        print_success(data, message=message)
+        try:
+            logger.log_tool_call(tool_name, True)
+        except Exception:
+            pass
+
+    def emit_error(code: str, message: str, suggestion: str | None = None):
+        print_error(code, message, suggestion=suggestion)
+        try:
+            logger.log_tool_call(tool_name, False, error_code=code, error_message=message)
+        except Exception:
+            pass
 
     if args.command == "stats":
         stats = sampler.get_stats()
-        print(json.dumps(stats, ensure_ascii=False, indent=2))
+        emit_success(stats, message="stats")
 
     elif args.command == "list":
         if args.type:
             samples = sampler.get_samples_by_type(args.type, args.limit)
         else:
             samples = sampler.get_best_samples(args.limit)
-
-        for s in samples:
-            print(f"\n[{s.scene_type}] 第 {s.chapter} 章 (score: {s.score:.2f})")
-            print(f"  {s.content[:100]}...")
+        emit_success([s.__dict__ for s in samples], message="samples")
 
     elif args.command == "extract":
         scenes = json.loads(args.scenes)
@@ -363,22 +378,24 @@ def main():
             chapter=args.chapter,
             content="",
             review_score=args.score,
-            scenes=scenes
+            scenes=scenes,
         )
 
+        added = []
+        skipped = []
         for c in candidates:
             if sampler.add_sample(c):
-                print(f"✓ 添加样本: {c.id} ({c.scene_type})")
+                added.append(c.id)
             else:
-                print(f"✗ 样本已存在: {c.id}")
+                skipped.append(c.id)
+        emit_success({"added": added, "skipped": skipped}, message="extracted")
 
     elif args.command == "select":
         samples = sampler.select_samples_for_chapter(args.outline, max_samples=args.max)
+        emit_success([s.__dict__ for s in samples], message="selected")
 
-        print(f"选择了 {len(samples)} 个风格样本:")
-        for s in samples:
-            print(f"\n[{s.scene_type}] 第 {s.chapter} 章")
-            print(f"  {s.content[:200]}...")
+    else:
+        emit_error("UNKNOWN_COMMAND", "未指定有效命令", suggestion="请查看 --help")
 
 
 if __name__ == "__main__":

+ 2 - 2
.claude/scripts/data_modules/tests/test_migrate_state_to_sqlite.py

@@ -134,8 +134,8 @@ def test_migrate_state_cli_main(tmp_path, monkeypatch, capsys):
     ]
     monkeypatch.setattr("sys.argv", args)
     migrate_module.main()
-    output = capsys.readouterr().out
-    assert "state.json" in output
+    output = json.loads(capsys.readouterr().out or "{}")
+    assert output.get("status") == "success"
 
 def test_migrate_state_backup_and_skips(temp_project):
     state = {

+ 12 - 8
.claude/scripts/data_modules/tests/test_sql_state_manager.py

@@ -167,10 +167,10 @@ def test_sql_state_manager_export_protagonist_and_cli(temp_project, monkeypatch,
     def run_cli(args):
         monkeypatch.setattr(sys, "argv", args)
         sql_state_manager_module.main()
-        return capsys.readouterr().out
+        return json.loads(capsys.readouterr().out or "{}")
 
     out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "get-protagonist"])
-    assert "未设置主角" in out
+    assert out.get("status") == "error"
 
     manager.upsert_entity(
         EntityData(id="xiaoyan", type="角色", name="萧炎", is_protagonist=True)
@@ -179,19 +179,23 @@ def test_sql_state_manager_export_protagonist_and_cli(temp_project, monkeypatch,
     assert exported["角色"]["xiaoyan"]["is_protagonist"] is True
 
     out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "get-protagonist"])
-    assert "萧炎" in out
+    assert out["status"] == "success"
+    assert out["data"].get("canonical_name") == "萧炎"
 
     out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "stats"])
-    assert "entities" in out
+    assert out["status"] == "success"
+    assert "entities" in out.get("data", {})
 
     out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "get-core-entities"])
-    assert "萧炎" in out
+    assert out["status"] == "success"
 
     out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "export-entities-v3"])
-    assert "角色" in out
+    assert out["status"] == "success"
+    assert "角色" in out.get("data", {})
 
     out = run_cli(["sql_state_manager", "--project-root", str(temp_project.project_root), "export-alias-index"])
-    assert isinstance(json.loads(out or "{}"), dict)
+    assert out["status"] == "success"
+    assert isinstance(out.get("data", {}), dict)
 
     payload = json.dumps({"entities_appeared": [], "entities_new": [], "state_changes": [], "relationships_new": []})
     out = run_cli([
@@ -204,4 +208,4 @@ def test_sql_state_manager_export_protagonist_and_cli(temp_project, monkeypatch,
         "--data",
         payload,
     ])
-    assert "已处理第 2 章" in out
+    assert out["status"] == "success"

+ 12 - 7
.claude/scripts/data_modules/tests/test_state_manager_extra.py

@@ -485,22 +485,27 @@ def test_state_manager_cli_commands(temp_project, monkeypatch, capsys):
         from data_modules import state_manager as sm
 
         sm.main()
-        return capsys.readouterr().out
+        out = capsys.readouterr().out
+        return json.loads(out)
 
     out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "get-progress"])
-    assert "current_chapter" in out
+    assert out["status"] == "success"
+    assert "current_chapter" in out.get("data", {})
 
     out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "get-entity", "--id", "missing"])
-    assert "未找到实体" in out
+    assert out["status"] == "error"
 
     out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "get-entity", "--id", "xiaoyan"])
-    assert "xiaoyan" in out
+    assert out["status"] == "success"
+    assert out["data"].get("id") == "xiaoyan"
 
     out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "list-entities", "--type", "角色"])
-    assert "xiaoyan" in out
+    assert out["status"] == "success"
+    assert any(e.get("id") == "xiaoyan" for e in out.get("data", []))
 
     out = run_cli(["state_manager", "--project-root", str(temp_project.project_root), "list-entities", "--tier", "核心"])
-    assert "xiaoyan" in out
+    assert out["status"] == "success"
+    assert any(e.get("id") == "xiaoyan" for e in out.get("data", []))
 
     payload = json.dumps({"entities_appeared": [], "entities_new": [], "state_changes": [], "relationships_new": []})
     out = run_cli([
@@ -513,7 +518,7 @@ def test_state_manager_cli_commands(temp_project, monkeypatch, capsys):
         "--data",
         payload,
     ])
-    assert "已处理第 1 章" in out
+    assert out["status"] == "success"
 
 
 def test_save_state_timeout(monkeypatch, temp_project):

+ 2 - 1
CLAUDE.md

@@ -28,7 +28,7 @@
 │   ├── reader-pull-checker.md  # 追读力检查器 (v5.3)
 │   ├── high-point-checker.md   # 爽点检查器 (v5.3)
 │   └── ...
-├── skills/                 # 6 个核心 Skill
+├── skills/                 # 7 个核心 Skill
 │   ├── webnovel-init/
 │   ├── webnovel-plan/
 │   ├── webnovel-write/     # 主写作流程 (v5.3)
@@ -54,6 +54,7 @@
 | `/webnovel-review [范围]` | 质量审查 |
 | `/webnovel-query [关键词]` | 信息查询 |
 | `/webnovel-resume` | 恢复中断任务 |
+| `/webnovel-learn [描述]` | 记忆写入 |
 
 ## v5.3 新增功能