Browse Source

feat: unify webnovel cli and project-root resolution

lingfengQAQ 3 months ago
parent
commit
7e8b32f984
43 changed files with 1551 additions and 304 deletions
  1. 8 0
      .claude/.gitignore
  2. 16 3
      .claude/agents/consistency-checker.md
  3. 32 8
      .claude/agents/context-agent.md
  4. 54 31
      .claude/agents/data-agent.md
  5. 15 1
      .claude/agents/pacing-checker.md
  6. 1 0
      .claude/references/claude-code-call-matrix.md
  7. 7 7
      .claude/references/entity-management-spec.md
  8. 6 7
      .claude/scripts/archive_manager.py
  9. 6 8
      .claude/scripts/backup_manager.py
  10. 65 25
      .claude/scripts/data_modules/__init__.py
  11. 96 0
      .claude/scripts/data_modules/cli_args.py
  12. 70 23
      .claude/scripts/data_modules/config.py
  13. 4 1
      .claude/scripts/data_modules/context_manager.py
  14. 9 2
      .claude/scripts/data_modules/entity_linker.py
  15. 19 13
      .claude/scripts/data_modules/index_manager.py
  16. 5 1
      .claude/scripts/data_modules/migrate_state_to_sqlite.py
  17. 9 3
      .claude/scripts/data_modules/rag_adapter.py
  18. 10 3
      .claude/scripts/data_modules/sql_state_manager.py
  19. 58 38
      .claude/scripts/data_modules/state_manager.py
  20. 10 3
      .claude/scripts/data_modules/style_sampler.py
  21. 128 0
      .claude/scripts/data_modules/tests/test_webnovel_unified_cli.py
  22. 257 0
      .claude/scripts/data_modules/webnovel.py
  23. 10 15
      .claude/scripts/extract_chapter_context.py
  24. 28 0
      .claude/scripts/init_project.py
  25. 261 8
      .claude/scripts/project_locator.py
  26. 2 1
      .claude/scripts/quality_trend_report.py
  27. 26 1
      .claude/scripts/run_tests.ps1
  28. 39 0
      .claude/scripts/runtime_compat.py
  29. 6 2
      .claude/scripts/security_utils.py
  30. 6 7
      .claude/scripts/status_reporter.py
  31. 36 0
      .claude/scripts/webnovel.py
  32. 6 4
      .claude/scripts/workflow_manager.py
  33. 24 2
      .claude/skills/webnovel-init/SKILL.md
  34. 44 12
      .claude/skills/webnovel-plan/SKILL.md
  35. 41 9
      .claude/skills/webnovel-query/SKILL.md
  36. 9 9
      .claude/skills/webnovel-query/references/system-data-flow.md
  37. 43 11
      .claude/skills/webnovel-resume/SKILL.md
  38. 2 2
      .claude/skills/webnovel-resume/references/system-data-flow.md
  39. 4 4
      .claude/skills/webnovel-resume/references/workflow-resume.md
  40. 48 16
      .claude/skills/webnovel-review/SKILL.md
  41. 29 22
      .claude/skills/webnovel-write/SKILL.md
  42. 1 1
      .claude/skills/webnovel-write/references/step-3-review-gate.md
  43. 1 1
      .claude/skills/webnovel-write/references/step-5-debt-switch.md

+ 8 - 0
.claude/.gitignore

@@ -25,3 +25,11 @@ Thumbs.db
 *.tmp
 *.temp
 .ace-tool/
+
+# Workspace pointer (local only)
+.webnovel-current-project
+
+# Local test scratch (do not commit)
+scripts/.tmp_pytest/
+scripts/.tmp_pytest_manual/
+scripts/zzz_test.db*

+ 16 - 3
.claude/agents/consistency-checker.md

@@ -170,13 +170,26 @@ Chapters {N} - {M}
 对于发现的 **CRITICAL** 级别问题,自动标记到 invalid_facts(pending):
 
 ```bash
-python -m data_modules.index_manager mark-invalid \
+# 解析脚本目录(优先项目内,其次父目录工作区,其次用户目录,其次插件目录)
+if [ -d "{PROJECT_ROOT}/.claude/scripts" ]; then
+  SCRIPTS_DIR="{PROJECT_ROOT}/.claude/scripts"
+elif [ -d "{PROJECT_ROOT}/../.claude/scripts" ]; then
+  SCRIPTS_DIR="{PROJECT_ROOT}/../.claude/scripts"
+elif [ -d "${HOME}/.claude/scripts" ]; then
+  SCRIPTS_DIR="${HOME}/.claude/scripts"
+elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
+  SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
+else
+  echo "ERROR: 未找到 scripts 目录(.claude/scripts)" >&2
+  exit 1
+fi
+
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "{PROJECT_ROOT}" index mark-invalid \
   --source-type entity \
   --source-id {entity_id} \
   --reason "{问题描述}" \
   --marked-by consistency-checker \
-  --chapter {current_chapter} \
-  --project-root "{PROJECT_ROOT}"
+  --chapter {current_chapter}
 ```
 
 > 注意:自动标记仅为 `pending`,需用户确认后才生效。

+ 32 - 8
.claude/agents/context-agent.md

@@ -98,14 +98,38 @@ tools: Read, Grep, Bash
 
 ## 执行流程(精简版)
 
+### Step -1: CLI 入口与脚本目录探测(必做)
+
+为避免 `PYTHONPATH` / `cd` / 参数顺序导致的隐性失败,所有 CLI 调用统一走:
+- `${SCRIPTS_DIR}/webnovel.py`
+
+```bash
+# 解析脚本目录(优先项目内,其次父目录工作区,其次用户目录,其次插件目录)
+if [ -d "{project_root}/.claude/scripts" ]; then
+  SCRIPTS_DIR="{project_root}/.claude/scripts"
+elif [ -d "{project_root}/../.claude/scripts" ]; then
+  SCRIPTS_DIR="{project_root}/../.claude/scripts"
+elif [ -d "${HOME}/.claude/scripts" ]; then
+  SCRIPTS_DIR="${HOME}/.claude/scripts"
+elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
+  SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
+else
+  echo "ERROR: 未找到 scripts 目录(.claude/scripts)" >&2
+  exit 1
+fi
+
+# 建议先确认解析出的 project_root,避免写到错误目录
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" where
+```
+
 ### Step 0: ContextManager 快照优先
 ```bash
-python -m data_modules.context_manager --chapter {NNNN} --project-root "{project_root}"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" context --chapter {NNNN}
 ```
 
 ### Step 0.5: Contract v2 上下文包(内置)
 ```bash
-python "${CLAUDE_PLUGIN_ROOT}/scripts/extract_chapter_context.py" --chapter {NNNN} --project-root "{project_root}" --format json
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" extract-context --chapter {NNNN} --format json
 ```
 
 - 必须读取:`writing_guidance.guidance_items`
@@ -119,16 +143,16 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/extract_chapter_context.py" --chapter {NNN
 
 ### Step 2: 追读力与债务(按需)
 ```bash
-python -m data_modules.index_manager get-recent-reading-power --limit 5 --project-root "{project_root}"
-python -m data_modules.index_manager get-pattern-usage-stats --last-n 20 --project-root "{project_root}"
-python -m data_modules.index_manager get-hook-type-stats --last-n 20 --project-root "{project_root}"
-python -m data_modules.index_manager get-debt-summary --project-root "{project_root}"
+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
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-hook-type-stats --last-n 20
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-debt-summary
 ```
 
 ### Step 3: 实体与最近出场 + 伏笔读取
 ```bash
-python -m data_modules.index_manager get-core-entities --project-root "{project_root}"
-python -m data_modules.index_manager recent-appearances --limit 20 --project-root "{project_root}"
+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` 读取:

+ 54 - 31
.claude/agents/data-agent.md

@@ -59,25 +59,49 @@ tools: Read, Write, Bash
 
 ## 执行流程
 
-### Step A: 加载上下文 (v5.1 SQL 查询)
+### Step -1: CLI 入口与脚本目录探测(必做)
 
-使用 Read 工具读取章节正文:
-- 章节正文: `正文/第0100章.md`
+为避免 `PYTHONPATH` / `cd` / 参数顺序导致的隐性失败,所有 CLI 调用统一走:
+- `${SCRIPTS_DIR}/webnovel.py`
 
-使用 Bash 工具从 index.db 查询已有实体:
 ```bash
-# v5.1: 从 SQLite 获取核心实体
-python -m data_modules.index_manager get-core-entities --project-root "{project_root}"
+# 解析脚本目录(优先项目内,其次父目录工作区,其次用户目录,其次插件目录)
+if [ -d "{project_root}/.claude/scripts" ]; then
+  SCRIPTS_DIR="{project_root}/.claude/scripts"
+elif [ -d "{project_root}/../.claude/scripts" ]; then
+  SCRIPTS_DIR="{project_root}/../.claude/scripts"
+elif [ -d "${HOME}/.claude/scripts" ]; then
+  SCRIPTS_DIR="${HOME}/.claude/scripts"
+elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
+  SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
+else
+  echo "ERROR: 未找到 scripts 目录(.claude/scripts)" >&2
+  exit 1
+fi
+
+# 建议先确认解析出的 project_root,避免写到错误目录
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" where
+```
 
-# v5.1: 获取实体别名
-python -m data_modules.index_manager get-aliases --entity "xiaoyan" --project-root "{project_root}"
+### Step A: 加载上下文 (v5.1 SQL 查询)
 
-# 查询最近出场记录
-python -m data_modules.index_manager recent-appearances --limit 20 --project-root "{project_root}"
+使用 Read 工具读取章节正文:
+- 章节正文: `正文/第0100章.md`
 
-# v5.1: 按别名查找实体(一对多)
-python -m data_modules.index_manager get-by-alias --alias "萧炎" --project-root "{project_root}"
-```
+使用 Bash 工具从 index.db 查询已有实体:
+ ```bash
+  # v5.1: 从 SQLite 获取核心实体
+  python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-core-entities
+  
+  # v5.1: 获取实体别名
+  python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-aliases --entity "xiaoyan"
+  
+  # 查询最近出场记录
+  python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index recent-appearances --limit 20
+  
+  # v5.1: 按别名查找实体(一对多)
+  python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index get-by-alias --alias "萧炎"
+  ```
 
 ### Step B: AI 实体提取
 
@@ -95,18 +119,18 @@ python -m data_modules.index_manager get-by-alias --alias "萧炎" --project-roo
 
 ### Step D: 写入存储 (v5.2 引入)
 
-**写入 index.db (实体/别名/状态变化/关系)**:
-```bash
-python -m data_modules.index_manager upsert-entity --data '{...}' --project-root "{project_root}"
-python -m data_modules.index_manager register-alias --alias "红衣女子" --entity "hongyi_girl" --type "角色" --project-root "{project_root}"
-python -m data_modules.index_manager record-state-change --data '{...}' --project-root "{project_root}"
-python -m data_modules.index_manager upsert-relationship --data '{...}' --project-root "{project_root}"
-```
+ **写入 index.db (实体/别名/状态变化/关系)**:
+ ```bash
+  python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index upsert-entity --data '{...}'
+  python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index register-alias --alias "红衣女子" --entity "hongyi_girl" --type "角色"
+  python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index record-state-change --data '{...}'
+  python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index upsert-relationship --data '{...}'
+ ```
 
-**更新精简版 state.json**:
-```bash
-python -m data_modules.state_manager process-chapter --chapter 100 --data '{...}' --project-root "{project_root}"
-```
+ **更新精简版 state.json**:
+ ```bash
+  python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" state process-chapter --chapter 100 --data '{...}'
+ ```
 
 写入内容 (v5.2 引入):
 - 更新 `progress.current_chapter`
@@ -152,11 +176,10 @@ hook_strength: "strong"
 ### Step G: 向量嵌入
 
 ```bash
-python -m data_modules.rag_adapter index-chapter \
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" rag index-chapter \
   --chapter 100 \
   --scenes '[...]' \
-  --summary "本章摘要文本" \
-  --project-root "{project_root}"
+  --summary "本章摘要文本"
 ```
 
 **父子索引规则 (v1.2)**:
@@ -174,15 +197,15 @@ if review_score >= 80:
 ```
 
 ```bash
-python -m data_modules.style_sampler extract --chapter 100 --score 85 --scenes '[...]' --project-root "{project_root}"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" style extract --chapter 100 --score 85 --scenes '[...]'
 ```
 
 ### Step I: 债务利息计算(v5.4 新增)
 
 **默认不自动触发**。仅在“开启债务追踪”或用户明确要求时执行:
-```bash
-python -m data_modules.index_manager accrue-interest --current-chapter {chapter} --project-root "{project_root}"
-```
+ ```bash
+ python "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" index accrue-interest --current-chapter {chapter}
+ ```
 
 此步骤会:
 - 对所有 `status='active'` 的债务计算利息(每章 10%)

+ 15 - 1
.claude/agents/pacing-checker.md

@@ -38,7 +38,21 @@ tools: Read, Grep, Bash
 **Optional: Use status_reporter for automated analysis**:
 ```bash
 # 获取 Strand Weave 详细分析(推荐)
-python "${CLAUDE_PLUGIN_ROOT}/scripts/status_reporter.py" --focus strand --project-root "${PROJECT_ROOT}"
+# 解析脚本目录(优先项目内,其次父目录工作区,其次用户目录,其次插件目录)
+if [ -d "${PROJECT_ROOT}/.claude/scripts" ]; then
+  SCRIPTS_DIR="${PROJECT_ROOT}/.claude/scripts"
+elif [ -d "${PROJECT_ROOT}/../.claude/scripts" ]; then
+  SCRIPTS_DIR="${PROJECT_ROOT}/../.claude/scripts"
+elif [ -d "${HOME}/.claude/scripts" ]; then
+  SCRIPTS_DIR="${HOME}/.claude/scripts"
+elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
+  SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
+else
+  echo "ERROR: 未找到 scripts 目录(.claude/scripts)" >&2
+  exit 1
+fi
+
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" status --focus strand
 
 # 输出包含:
 # - Quest/Fire/Constellation 占比统计

+ 1 - 0
.claude/references/claude-code-call-matrix.md

@@ -22,6 +22,7 @@
 
 | 脚本 | 主要触发方 | 触发节点 | 备注 |
 |---|---|---|---|
+| `.claude/scripts/webnovel.py` | 所有 Skills / Agents | 任何需要调用 CLI 的节点 | **统一入口**:负责解析真实 book project_root,并转发到 `data_modules/*` 或 `scripts/*.py`,避免 `PYTHONPATH/cd/参数顺序` 导致的隐性失败 |
 | `.claude/scripts/update_state.py` | `webnovel-plan` Skill | 章纲/卷规划落盘后更新 `state.json` | 也可被自动化脚本调用;默认不是人工常规入口 |
 | `.claude/scripts/status_reporter.py` | `webnovel-query` Skill / `pacing-checker` Agent(可选) | 查询分析或节奏审查时 | 产出健康报告与紧急度分析 |
 | `.claude/scripts/workflow_manager.py` | `webnovel-resume` Skill | 恢复流程 detect/cleanup/clear | 仅恢复场景触发 |

+ 7 - 7
.claude/references/entity-management-spec.md

@@ -137,19 +137,19 @@ index.db 更新完成
 
 ```bash
 # 查询实体
-python -m data_modules.index_manager get-entity --id "xiaoyan" --project-root "."
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" index get-entity --id "xiaoyan"
 
 # 查询核心实体
-python -m data_modules.index_manager get-core-entities --project-root "."
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" index get-core-entities
 
 # 通过别名查找
-python -m data_modules.index_manager get-by-alias --alias "萧炎" --project-root "."
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" index get-by-alias --alias "萧炎"
 
 # 查询状态变化历史
-python -m data_modules.index_manager get-state-changes --entity "xiaoyan" --project-root "."
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" index get-state-changes --entity "xiaoyan"
 
 # 查询关系
-python -m data_modules.index_manager get-relationships --entity "xiaoyan" --project-root "."
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" index get-relationships --entity "xiaoyan"
 ```
 
 ---
@@ -265,10 +265,10 @@ v5.1 允许 **aliases 一对多**:同一别名可以指向多个实体。
 
 ```bash
 # 运行迁移脚本
-python -m data_modules.migrate_state_to_sqlite --project-root "." --backup
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" migrate --backup
 
 # 验证迁移结果
-python -m data_modules.index_manager stats --project-root "."
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" index stats
 ```
 
 迁移后:

+ 6 - 7
.claude/scripts/archive_manager.py

@@ -543,13 +543,12 @@ def main():
 
     args = parser.parse_args()
 
-    # 创建管理器(支持从仓库根目录运行)
-    project_root = args.project_root
-    if project_root is None and not (Path.cwd() / ".webnovel" / "state.json").exists():
-        try:
-            project_root = str(resolve_project_root())
-        except FileNotFoundError:
-            project_root = None
+    # 解析项目根目录(允许传入“工作区根目录”,统一解析到真正的 book project_root)
+    try:
+        project_root = str(resolve_project_root(args.project_root) if args.project_root else resolve_project_root())
+    except FileNotFoundError as exc:
+        print(f"❌ 无法定位项目根目录(需要包含 .webnovel/state.json): {exc}", file=sys.stderr)
+        sys.exit(1)
 
     manager = ArchiveManager(project_root=project_root)
 

+ 6 - 8
.claude/scripts/backup_manager.py

@@ -433,14 +433,12 @@ def main():
 
     args = parser.parse_args()
 
-    # 解析项目根目录(支持从仓库根目录运行)
-    project_root = args.project_root
-    if project_root == '.' and not (Path('.') / '.webnovel' / 'state.json').exists():
-        try:
-            project_root = str(resolve_project_root())
-        except FileNotFoundError:
-            # 维持向后兼容:仍然使用用户提供的 cwd
-            project_root = args.project_root
+    # 解析项目根目录(允许传入“工作区根目录”,统一解析到真正的 book project_root)
+    try:
+        project_root = str(resolve_project_root(args.project_root))
+    except FileNotFoundError as exc:
+        print(f"❌ 无法定位项目根目录(需要包含 .webnovel/state.json): {exc}", file=sys.stderr)
+        sys.exit(1)
 
     # 创建管理器
     manager = GitBackupManager(project_root)

+ 65 - 25
.claude/scripts/data_modules/__init__.py

@@ -1,34 +1,22 @@
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 """
-Data Modules - 数据链模块包
+Data Modules - 数据链模块包
 
-用于 webnovel-writer 的数据处理
-- 实体消歧 (entity_linker)
-- 状态管理 (state_manager)
-- 索引管理 (index_manager)
-- RAG 检索 (rag_adapter)
-- 风格样本 (style_sampler)
-- API 客户端 (api_client) - 只有 Embed + Rerank
+注意
+- 这里采用延迟导入(lazy import),避免在执行 `python -m data_modules.xxx` 时,
+  因包级 __init__ 提前导入子模块而触发 runpy 的 RuntimeWarning。
+- 推荐用法永远安全:
+    from data_modules.index_manager import IndexManager
+  但为了兼容历史代码,也保留:
+    from data_modules import IndexManager
 """
 
-from .config import DataModulesConfig, get_config, set_project_root
-from .api_client import ModalAPIClient, get_client
-from .entity_linker import EntityLinker, DisambiguationResult
-from .state_manager import StateManager, EntityState, Relationship, StateChange
-from .index_manager import (
-    IndexManager,
-    ChapterMeta,
-    SceneMeta,
-    ReviewMetrics,
-    RelationshipEventMeta,
-)
-from .rag_adapter import RAGAdapter, SearchResult
-from .context_manager import ContextManager
-from .context_ranker import ContextRanker
-from .snapshot_manager import SnapshotManager
-from .query_router import QueryRouter
-from .style_sampler import StyleSampler, StyleSample, SceneType
+from __future__ import annotations
+
+from importlib import import_module
+from typing import Any
+
 
 __all__ = [
     # Config
@@ -64,3 +52,55 @@ __all__ = [
     "StyleSample",
     "SceneType",
 ]
+
+
+_LAZY_EXPORTS: dict[str, tuple[str, str]] = {
+    # Config
+    "DataModulesConfig": (".config", "DataModulesConfig"),
+    "get_config": (".config", "get_config"),
+    "set_project_root": (".config", "set_project_root"),
+    # API Client
+    "ModalAPIClient": (".api_client", "ModalAPIClient"),
+    "get_client": (".api_client", "get_client"),
+    # Entity Linker
+    "EntityLinker": (".entity_linker", "EntityLinker"),
+    "DisambiguationResult": (".entity_linker", "DisambiguationResult"),
+    # State Manager
+    "StateManager": (".state_manager", "StateManager"),
+    "EntityState": (".state_manager", "EntityState"),
+    "Relationship": (".state_manager", "Relationship"),
+    "StateChange": (".state_manager", "StateChange"),
+    # Index Manager
+    "IndexManager": (".index_manager", "IndexManager"),
+    "ChapterMeta": (".index_manager", "ChapterMeta"),
+    "SceneMeta": (".index_manager", "SceneMeta"),
+    "ReviewMetrics": (".index_manager", "ReviewMetrics"),
+    "RelationshipEventMeta": (".index_manager", "RelationshipEventMeta"),
+    # RAG Adapter
+    "RAGAdapter": (".rag_adapter", "RAGAdapter"),
+    "SearchResult": (".rag_adapter", "SearchResult"),
+    "ContextManager": (".context_manager", "ContextManager"),
+    "ContextRanker": (".context_ranker", "ContextRanker"),
+    "SnapshotManager": (".snapshot_manager", "SnapshotManager"),
+    "QueryRouter": (".query_router", "QueryRouter"),
+    # Style Sampler
+    "StyleSampler": (".style_sampler", "StyleSampler"),
+    "StyleSample": (".style_sampler", "StyleSample"),
+    "SceneType": (".style_sampler", "SceneType"),
+}
+
+
+def __getattr__(name: str) -> Any:  # pragma: no cover
+    if name not in _LAZY_EXPORTS:
+        raise AttributeError(name)
+
+    module_path, attr = _LAZY_EXPORTS[name]
+    module = import_module(module_path, __name__)
+    value = getattr(module, attr)
+    globals()[name] = value  # cache
+    return value
+
+
+def __dir__() -> list[str]:  # pragma: no cover
+    return sorted(set(list(globals().keys()) + list(_LAZY_EXPORTS.keys())))
+

+ 96 - 0
.claude/scripts/data_modules/cli_args.py

@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+CLI 参数兼容工具。
+
+背景:
+- data_modules 下的 CLI 普遍使用 argparse + subparsers。
+- argparse 的全局参数(例如 --project-root)要求出现在子命令之前:
+    python -m data_modules.index_manager --project-root X get-core-entities
+  但实际写作流程里(skills/agents 文档、工具调用)经常把 --project-root 放在子命令之后:
+    python -m data_modules.index_manager get-core-entities --project-root X
+  这会直接报 "unrecognized arguments"(见 issues7 日志)。
+
+这里提供一个轻量的 argv 预处理:把 --project-root 从任意位置提取出来并前置,
+让原有 argparse 定义无需大改即可兼容两种写法。
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+from typing import Any
+from typing import List, Optional, Tuple
+
+
+def _extract_flag_value(argv: List[str], flag: str) -> Tuple[Optional[str], List[str]]:
+    """
+    Extract a flag value from argv.
+
+    Supports:
+    - --flag VALUE
+    - --flag=VALUE
+
+    Returns:
+    - (value, remaining_argv)
+    - value uses the *last* occurrence when repeated.
+    - if a dangling `--flag` has no value, it is kept in remaining_argv for argparse to raise.
+    """
+    value: Optional[str] = None
+    rest: List[str] = []
+    i = 0
+    while i < len(argv):
+        token = argv[i]
+        if token == flag:
+            if i + 1 < len(argv):
+                value = argv[i + 1]
+                i += 2
+                continue
+            # Dangling flag; keep it so argparse can error out properly.
+            rest.append(token)
+            i += 1
+            continue
+        if token.startswith(flag + "="):
+            value = token.split("=", 1)[1]
+            i += 1
+            continue
+        rest.append(token)
+        i += 1
+    return value, rest
+
+
+def normalize_global_project_root(argv: List[str], *, flag: str = "--project-root") -> List[str]:
+    """
+    Normalize argv so a global `--project-root` (when present) is moved before subcommands.
+
+    This makes argparse+subparsers accept both:
+    - `... --project-root X cmd ...`
+    - `... cmd ... --project-root X`
+    """
+    value, rest = _extract_flag_value(argv, flag)
+    if value is None:
+        return argv
+    return [flag, value] + rest
+
+
+def load_json_arg(raw: str) -> Any:
+    """
+    解析 CLI 传入的 JSON 参数,支持两种形式:
+    - 直接 JSON 字符串:'{"a":1}'
+    - @ 文件路径:'@data.json'(从文件读取 JSON,避免 shell 引号地狱)
+      - 特例:'@-' 表示从 stdin 读取
+    """
+    if raw is None:
+        raise ValueError("missing json arg")
+    text = str(raw).strip()
+    if text.startswith("@"):
+        target = text[1:].strip()
+        if not target:
+            raise ValueError("invalid json arg: '@' without path")
+        if target == "-":
+            content = sys.stdin.read()
+        else:
+            content = Path(target).read_text(encoding="utf-8")
+        return json.loads(content)
+    return json.loads(text)

+ 70 - 23
.claude/scripts/data_modules/config.py

@@ -13,30 +13,66 @@ from pathlib import Path
 from dataclasses import dataclass, field
 from typing import Optional
 
+from runtime_compat import normalize_windows_path
+
 from .context_weights import TEMPLATE_WEIGHTS_DYNAMIC_DEFAULT
 
-# 加载 .env 文件
+def _get_user_claude_root() -> Path:
+    raw = os.environ.get("WEBNOVEL_CLAUDE_HOME") or os.environ.get("CLAUDE_HOME")
+    if raw:
+        try:
+            return normalize_windows_path(raw).expanduser().resolve()
+        except Exception:
+            return normalize_windows_path(raw).expanduser()
+    return (Path.home() / ".claude").resolve()
+
+
+def _load_dotenv_file(env_path: Path, *, override: bool = False) -> bool:
+    if not env_path.exists():
+        return False
+    try:
+        with open(env_path, "r", encoding="utf-8") as f:
+            for line in f:
+                line = line.strip()
+                if line and not line.startswith("#") and "=" in line:
+                    key, _, value = line.partition("=")
+                    key = key.strip()
+                    value = value.strip()
+                    if not key:
+                        continue
+                    # 默认不覆盖已有环境变量(保持“显式 > .env”优先级)
+                    if override or key not in os.environ:
+                        os.environ[key] = value
+        return True
+    except Exception:
+        return False
+
+
 def _load_dotenv():
-    """从项目根目录加载 .env 文件"""
-    # 尝试多个可能的位置
-    possible_paths = [
-        Path.cwd() / ".env",
-        Path(__file__).parent.parent.parent.parent / ".env",  # .claude/scripts/data_modules -> 项目根目录
-    ]
-
-    for env_path in possible_paths:
-        if env_path.exists():
-            with open(env_path, "r", encoding="utf-8") as f:
-                for line in f:
-                    line = line.strip()
-                    if line and not line.startswith("#") and "=" in line:
-                        key, _, value = line.partition("=")
-                        key = key.strip()
-                        value = value.strip()
-                        # 只在环境变量未设置时才从 .env 加载
-                        if key and key not in os.environ:
-                            os.environ[key] = value
-            break
+    """
+    加载 .env 文件(best-effort)。
+
+    约定:
+    - 项目级 `.env`(当前工作目录下)优先;
+    - 全局 `.env` 作为兜底:`~/.claude/webnovel-writer/.env`
+    """
+    # 1) 当前目录(常见:用户从项目根目录执行)
+    _load_dotenv_file(Path.cwd() / ".env", override=False)
+
+    # 2) 用户级全局(常见:skills/agents 全局安装,API key 放这里最省心)
+    global_env = _get_user_claude_root() / "webnovel-writer" / ".env"
+    _load_dotenv_file(global_env, override=False)
+
+
+def _load_project_dotenv(project_root: Path) -> None:
+    """
+    加载某个项目根目录下的 `.env`(best-effort)。
+    注意:不覆盖已存在环境变量,避免意外串台。
+    """
+    try:
+        _load_dotenv_file(Path(project_root) / ".env", override=False)
+    except Exception:
+        return
 
 _load_dotenv()
 
@@ -281,7 +317,10 @@ class DataModulesConfig:
 
     @classmethod
     def from_project_root(cls, project_root: str | Path) -> "DataModulesConfig":
-        return cls(project_root=Path(project_root))
+        root = normalize_windows_path(project_root).expanduser().resolve()
+        # 在构造配置前加载项目级 `.env`,以确保 EMBED_*/RERANK_* 等字段可生效
+        _load_project_dotenv(root)
+        return cls(project_root=root)
 
 
 _default_config: Optional[DataModulesConfig] = None
@@ -292,7 +331,15 @@ def get_config(project_root: Optional[Path] = None) -> DataModulesConfig:
     if project_root is not None:
         return DataModulesConfig.from_project_root(project_root)
     if _default_config is None:
-        _default_config = DataModulesConfig()
+        # 默认不要盲目以 CWD 作为 project_root(很容易写到错误目录)。
+        # 使用统一的 project_locator 自动探测:
+        # - 支持 WEBNOVEL_PROJECT_ROOT
+        # - 支持 `.claude/.webnovel-current-project` 指针文件
+        # - 支持从当前目录/父目录寻找 `.webnovel/state.json`
+        from project_locator import resolve_project_root
+
+        root = resolve_project_root()
+        _default_config = DataModulesConfig.from_project_root(root)
     return _default_config
 
 

+ 4 - 1
.claude/scripts/data_modules/context_manager.py

@@ -745,9 +745,12 @@ def main():
 
     config = None
     if args.project_root:
+        # 允许传入“工作区根目录”,统一解析到真正的 book project_root(必须包含 .webnovel/state.json)
+        from project_locator import resolve_project_root
         from .config import DataModulesConfig
 
-        config = DataModulesConfig.from_project_root(args.project_root)
+        resolved_root = resolve_project_root(args.project_root)
+        config = DataModulesConfig.from_project_root(resolved_root)
 
     manager = ContextManager(config)
     try:

+ 9 - 2
.claude/scripts/data_modules/entity_linker.py

@@ -180,7 +180,9 @@ class EntityLinker:
 
 def main():
     import argparse
+    import sys
     from .cli_output import print_success, print_error
+    from .cli_args import normalize_global_project_root
     from .index_manager import IndexManager
 
     parser = argparse.ArgumentParser(description="Entity Linker CLI (v5.4 SQLite)")
@@ -208,13 +210,18 @@ def main():
     list_parser.add_argument("--entity", required=True, help="实体ID")
     list_parser.add_argument("--type", help="实体类型")
 
-    args = parser.parse_args()
+    argv = normalize_global_project_root(sys.argv[1:])
+    args = parser.parse_args(argv)
 
     # 初始化
     config = None
     if args.project_root:
+        # 允许传入“工作区根目录”,统一解析到真正的 book project_root(必须包含 .webnovel/state.json)
+        from project_locator import resolve_project_root
         from .config import DataModulesConfig
-        config = DataModulesConfig.from_project_root(args.project_root)
+
+        resolved_root = resolve_project_root(args.project_root)
+        config = DataModulesConfig.from_project_root(resolved_root)
 
     linker = EntityLinker(config)
     logger = IndexManager(config)

+ 19 - 13
.claude/scripts/data_modules/index_manager.py

@@ -636,7 +636,9 @@ class IndexManager(IndexChapterMixin, IndexEntityMixin, IndexDebtMixin, IndexRea
 
 def main():
     import argparse
+    import sys
     from .cli_output import print_success, print_error
+    from .cli_args import normalize_global_project_root, load_json_arg
 
     parser = argparse.ArgumentParser(description="Index Manager CLI (v5.4)")
     parser.add_argument("--project-root", type=str, help="项目根目录")
@@ -864,15 +866,19 @@ def main():
         "--data", required=True, help="JSON 格式的章节追读力元数据"
     )
 
-    args = parser.parse_args()
+    argv = normalize_global_project_root(sys.argv[1:])
+    args = parser.parse_args(argv)
     command_started_at = time.perf_counter()
 
     # 初始化
     config = None
     if args.project_root:
+        # 允许传入“工作区根目录”,统一解析到真正的 book project_root(必须包含 .webnovel/state.json)
+        from project_locator import resolve_project_root
         from .config import DataModulesConfig
 
-        config = DataModulesConfig.from_project_root(args.project_root)
+        resolved_root = resolve_project_root(args.project_root)
+        config = DataModulesConfig.from_project_root(resolved_root)
 
     manager = IndexManager(config)
     tool_name = f"index_manager:{args.command or 'unknown'}"
@@ -935,8 +941,8 @@ def main():
         emit_success(scenes, message="scenes")
 
     elif args.command == "process-chapter":
-        entities = json.loads(args.entities)
-        scenes = json.loads(args.scenes)
+        entities = load_json_arg(args.entities)
+        scenes = load_json_arg(args.scenes)
         stats = manager.process_chapter_data(
             chapter=args.chapter,
             title=args.title,
@@ -1037,7 +1043,7 @@ def main():
 
     elif args.command == "record-relationship-event":
         try:
-            data = json.loads(args.data)
+            data = load_json_arg(args.data)
         except (TypeError, ValueError, json.JSONDecodeError):
             emit_error("INVALID_RELATIONSHIP_EVENT", "关系事件 JSON 无效")
         else:
@@ -1061,7 +1067,7 @@ def main():
                 emit_error("INVALID_RELATIONSHIP_EVENT", "关系事件参数无效,未写入")
 
     elif args.command == "upsert-entity":
-        data = json.loads(args.data)
+        data = load_json_arg(args.data)
         entity = EntityMeta(
             id=data["id"],
             type=data["type"],
@@ -1078,7 +1084,7 @@ def main():
         emit_success({"id": entity.id, "created": is_new}, message="entity_upserted")
 
     elif args.command == "upsert-relationship":
-        data = json.loads(args.data)
+        data = load_json_arg(args.data)
         rel = RelationshipMeta(
             from_entity=data["from_entity"],
             to_entity=data["to_entity"],
@@ -1093,7 +1099,7 @@ def main():
         )
 
     elif args.command == "record-state-change":
-        data = json.loads(args.data)
+        data = load_json_arg(args.data)
         change = StateChangeMeta(
             entity_id=data["entity_id"],
             field=data["field"],
@@ -1129,7 +1135,7 @@ def main():
         emit_success(rows, message="invalid_list")
 
     elif args.command == "save-review-metrics":
-        data = json.loads(args.data)
+        data = load_json_arg(args.data)
         metrics = ReviewMetrics(
             start_chapter=data["start_chapter"],
             end_chapter=data["end_chapter"],
@@ -1155,7 +1161,7 @@ def main():
         emit_success(stats, message="review_trend_stats")
 
     elif args.command == "save-writing-checklist-score":
-        data = json.loads(args.data)
+        data = load_json_arg(args.data)
         metrics = WritingChecklistScoreMeta(
             chapter=data["chapter"],
             template=data.get("template", "plot"),
@@ -1243,7 +1249,7 @@ def main():
             emit_success(result, message="debt_payment", chapter=args.chapter)
 
     elif args.command == "create-override-contract":
-        data = json.loads(args.data)
+        data = load_json_arg(args.data)
         contract = OverrideContractMeta(
             chapter=data["chapter"],
             constraint_type=data["constraint_type"],
@@ -1258,7 +1264,7 @@ def main():
         emit_success({"id": contract_id}, message="override_contract_created")
 
     elif args.command == "create-debt":
-        data = json.loads(args.data)
+        data = load_json_arg(args.data)
         debt = ChaseDebtMeta(
             debt_type=data["debt_type"],
             original_amount=data.get("original_amount", 1.0),
@@ -1280,7 +1286,7 @@ def main():
             emit_error("NOT_FOUND", f"未找到 Override Contract #{args.contract_id}")
 
     elif args.command == "save-chapter-reading-power":
-        data = json.loads(args.data)
+        data = load_json_arg(args.data)
         meta = ChapterReadingPowerMeta(
             chapter=data["chapter"],
             hook_type=data.get("hook_type", ""),

+ 5 - 1
.claude/scripts/data_modules/migrate_state_to_sqlite.py

@@ -336,7 +336,11 @@ def main():
 
     args = parser.parse_args()
 
-    config = DataModulesConfig.from_project_root(args.project_root)
+    # 允许传入“工作区根目录”,统一解析到真正的 book project_root(必须包含 .webnovel/state.json)
+    from project_locator import resolve_project_root
+
+    resolved_root = resolve_project_root(args.project_root)
+    config = DataModulesConfig.from_project_root(resolved_root)
     backup = not args.no_backup
     logger = IndexManager(config)
     tool_name = "migrate_state_to_sqlite"

+ 9 - 3
.claude/scripts/data_modules/rag_adapter.py

@@ -1390,7 +1390,9 @@ class RAGAdapter:
 
 def main():
     import argparse
+    import sys
     from .cli_output import print_success, print_error
+    from .cli_args import normalize_global_project_root, load_json_arg
 
     parser = argparse.ArgumentParser(description="RAG Adapter CLI")
     parser.add_argument("--project-root", type=str, help="项目根目录")
@@ -1422,15 +1424,19 @@ def main():
         help="中心实体列表(JSON 数组或逗号分隔)",
     )
 
-    args = parser.parse_args()
+    argv = normalize_global_project_root(sys.argv[1:])
+    args = parser.parse_args(argv)
     command_started_at = time.perf_counter()
 
     # 初始化
     config = None
     if args.project_root:
+        # 允许传入“工作区根目录”,统一解析到真正的 book project_root(必须包含 .webnovel/state.json)
+        from project_locator import resolve_project_root
         from .config import DataModulesConfig
 
-        config = DataModulesConfig.from_project_root(args.project_root)
+        resolved_root = resolve_project_root(args.project_root)
+        config = DataModulesConfig.from_project_root(resolved_root)
 
     adapter = RAGAdapter(config)
     tool_name = f"rag_adapter:{args.command or 'unknown'}"
@@ -1468,7 +1474,7 @@ def main():
         emit_success(stats, message="stats")
 
     elif args.command == "index-chapter":
-        scenes = json.loads(args.scenes)
+        scenes = load_json_arg(args.scenes)
         chunks = []
 
         # summary chunk

+ 10 - 3
.claude/scripts/data_modules/sql_state_manager.py

@@ -491,7 +491,9 @@ class SQLStateManager:
 
 def main():
     import argparse
+    import sys
     from .cli_output import print_success, print_error
+    from .cli_args import normalize_global_project_root, load_json_arg
     from .index_manager import IndexManager
 
     parser = argparse.ArgumentParser(description="SQL State Manager CLI (v5.4)")
@@ -519,13 +521,18 @@ def main():
     process_parser.add_argument("--chapter", type=int, required=True)
     process_parser.add_argument("--data", required=True, help="JSON 格式的章节数据")
 
-    args = parser.parse_args()
+    argv = normalize_global_project_root(sys.argv[1:])
+    args = parser.parse_args(argv)
 
     # 初始化
     config = None
     if args.project_root:
+        # 允许传入“工作区根目录”,统一解析到真正的 book project_root(必须包含 .webnovel/state.json)
+        from project_locator import resolve_project_root
         from .config import DataModulesConfig
-        config = DataModulesConfig.from_project_root(args.project_root)
+
+        resolved_root = resolve_project_root(args.project_root)
+        config = DataModulesConfig.from_project_root(resolved_root)
 
     manager = SQLStateManager(config)
     logger = IndexManager(config)
@@ -569,7 +576,7 @@ def main():
         emit_success(data, message="alias_index")
 
     elif args.command == "process-chapter":
-        data = json.loads(args.data)
+        data = load_json_arg(args.data)
         stats = manager.process_chapter_entities(
             chapter=args.chapter,
             entities_appeared=data.get("entities_appeared", []),

+ 58 - 38
.claude/scripts/data_modules/state_manager.py

@@ -759,47 +759,60 @@ class StateManager:
 
     def update_entity(self, entity_id: str, updates: Dict[str, Any], entity_type: str = None) -> bool:
         """更新实体属性(v5.0 引入,v5.4 沿用)"""
-        # 查找实体
-        if entity_type:
-            if entity_id not in self._state.get("entities_v3", {}).get(entity_type, {}):
-                return False
-            entity = self._state["entities_v3"][entity_type][entity_id]
-        else:
-            entity_type = self.get_entity_type(entity_id)
-            if not entity_type:
-                return False
-            entity = self._state["entities_v3"][entity_type][entity_id]
+        # v5.1+ SQLite-first:
+        # - entity_type 可能来自 SQLite(entities 表),但 state.json 不再持久化 entities_v3。
+        # - 因此不能假设 self._state["entities_v3"][type][id] 一定存在(issues7 日志曾 KeyError)。
+        resolved_type = entity_type or self.get_entity_type(entity_id)
+        if not resolved_type:
+            return False
+        if resolved_type not in self.ENTITY_TYPES:
+            resolved_type = "角色"
+
+        # 仅在内存存在 v3 实体时才更新内存快照(不强行创建,避免 state.json 再膨胀)
+        entities_v3 = self._state.get("entities_v3")
+        entity = None
+        if isinstance(entities_v3, dict):
+            bucket = entities_v3.get(resolved_type)
+            if isinstance(bucket, dict):
+                entity = bucket.get(entity_id)
+
+        # SQLite 启用时,即使内存实体缺失,也要记录 patch,确保 current 能增量写回 index.db
+        patch = None
+        if self._sql_state_manager:
+            patch = self._pending_entity_patches.get((resolved_type, entity_id))
+            if patch is None:
+                patch = _EntityPatch(entity_type=resolved_type, entity_id=entity_id)
+                self._pending_entity_patches[(resolved_type, entity_id)] = patch
 
+        if entity is None and patch is None:
+            return False
+
+        did_any = False
         for key, value in updates.items():
             if key == "attributes" and isinstance(value, dict):
-                # v5.0 引入: attributes 存在 current 字段
-                if "current" not in entity:
-                    entity["current"] = {}
-                entity["current"].update(value)
-                # 记录补丁(current 增量)
-                patch = self._pending_entity_patches.get((entity_type, entity_id))
-                if patch is None:
-                    patch = _EntityPatch(entity_type=entity_type, entity_id=entity_id)
-                    self._pending_entity_patches[(entity_type, entity_id)] = patch
-                patch.current_updates.update(value)
+                if entity is not None:
+                    if "current" not in entity:
+                        entity["current"] = {}
+                    entity["current"].update(value)
+                if patch is not None:
+                    patch.current_updates.update(value)
+                did_any = True
             elif key == "current" and isinstance(value, dict):
-                if "current" not in entity:
-                    entity["current"] = {}
-                entity["current"].update(value)
-                patch = self._pending_entity_patches.get((entity_type, entity_id))
-                if patch is None:
-                    patch = _EntityPatch(entity_type=entity_type, entity_id=entity_id)
-                    self._pending_entity_patches[(entity_type, entity_id)] = patch
-                patch.current_updates.update(value)
+                if entity is not None:
+                    if "current" not in entity:
+                        entity["current"] = {}
+                    entity["current"].update(value)
+                if patch is not None:
+                    patch.current_updates.update(value)
+                did_any = True
             else:
-                entity[key] = value
-                patch = self._pending_entity_patches.get((entity_type, entity_id))
-                if patch is None:
-                    patch = _EntityPatch(entity_type=entity_type, entity_id=entity_id)
-                    self._pending_entity_patches[(entity_type, entity_id)] = patch
-                patch.top_updates[key] = value
+                if entity is not None:
+                    entity[key] = value
+                if patch is not None:
+                    patch.top_updates[key] = value
+                did_any = True
 
-        return True
+        return did_any
 
     def update_entity_appearance(self, entity_id: str, chapter: int, entity_type: str = None):
         """更新实体出场章节"""
@@ -1207,8 +1220,10 @@ class StateManager:
 
 def main():
     import argparse
+    import sys
     from pydantic import ValidationError
     from .cli_output import print_success, print_error
+    from .cli_args import normalize_global_project_root, load_json_arg
     from .schemas import validate_data_agent_output, format_validation_error, normalize_data_agent_output
     from .index_manager import IndexManager
 
@@ -1234,14 +1249,19 @@ def main():
     process_parser.add_argument("--chapter", type=int, required=True, help="章节号")
     process_parser.add_argument("--data", required=True, help="JSON 格式的处理结果")
 
-    args = parser.parse_args()
+    argv = normalize_global_project_root(sys.argv[1:])
+    args = parser.parse_args(argv)
     command_started_at = time.perf_counter()
 
     # 初始化
     config = None
     if args.project_root:
+        # 允许传入“工作区根目录”,统一解析到真正的 book project_root(必须包含 .webnovel/state.json)
+        from project_locator import resolve_project_root
         from .config import DataModulesConfig
-        config = DataModulesConfig.from_project_root(args.project_root)
+
+        resolved_root = resolve_project_root(args.project_root)
+        config = DataModulesConfig.from_project_root(resolved_root)
 
     manager = StateManager(config)
     logger = IndexManager(config)
@@ -1297,7 +1317,7 @@ def main():
         emit_success(payload, message="entities")
 
     elif args.command == "process-chapter":
-        data = json.loads(args.data)
+        data = load_json_arg(args.data)
         validated = None
         last_exc = None
         for _ in range(3):

+ 10 - 3
.claude/scripts/data_modules/style_sampler.py

@@ -310,7 +310,9 @@ class StyleSampler:
 
 def main():
     import argparse
+    import sys
     from .cli_output import print_success, print_error
+    from .cli_args import normalize_global_project_root, load_json_arg
     from .index_manager import IndexManager
 
     parser = argparse.ArgumentParser(description="Style Sampler CLI")
@@ -337,14 +339,19 @@ def main():
     select_parser.add_argument("--outline", required=True, help="章节大纲")
     select_parser.add_argument("--max", type=int, default=3)
 
-    args = parser.parse_args()
+    argv = normalize_global_project_root(sys.argv[1:])
+    args = parser.parse_args(argv)
     command_started_at = time.perf_counter()
 
     # 初始化
     config = None
     if args.project_root:
+        # 允许传入“工作区根目录”,统一解析到真正的 book project_root(必须包含 .webnovel/state.json)
+        from project_locator import resolve_project_root
         from .config import DataModulesConfig
-        config = DataModulesConfig.from_project_root(args.project_root)
+
+        resolved_root = resolve_project_root(args.project_root)
+        config = DataModulesConfig.from_project_root(resolved_root)
 
     sampler = StyleSampler(config)
     logger = IndexManager(config)
@@ -390,7 +397,7 @@ def main():
         emit_success([s.__dict__ for s in samples], message="samples")
 
     elif args.command == "extract":
-        scenes = json.loads(args.scenes)
+        scenes = load_json_arg(args.scenes)
         candidates = sampler.extract_candidates(
             chapter=args.chapter,
             content="",

+ 128 - 0
.claude/scripts/data_modules/tests/test_webnovel_unified_cli.py

@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import sys
+from pathlib import Path
+
+import pytest
+
+
+def _ensure_scripts_on_path() -> None:
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+
+def _load_webnovel_module():
+    _ensure_scripts_on_path()
+    import data_modules.webnovel as webnovel_module
+
+    return webnovel_module
+
+
+def test_init_does_not_resolve_existing_project_root(monkeypatch):
+    module = _load_webnovel_module()
+
+    called = {}
+
+    def _fake_run_script(script_name, argv):
+        called["script_name"] = script_name
+        called["argv"] = list(argv)
+        return 0
+
+    def _fail_resolve(_explicit_project_root=None):
+        raise AssertionError("init 子命令不应触发 project_root 解析")
+
+    monkeypatch.setenv("WEBNOVEL_PROJECT_ROOT", r"D:\invalid\root")
+    monkeypatch.setattr(module, "_run_script", _fake_run_script)
+    monkeypatch.setattr(module, "_resolve_root", _fail_resolve)
+    monkeypatch.setattr(sys, "argv", ["webnovel", "init", "proj-dir", "测试书", "修仙"])
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    assert int(exc.value.code or 0) == 0
+    assert called["script_name"] == "init_project.py"
+    assert called["argv"] == ["proj-dir", "测试书", "修仙"]
+
+
+def test_extract_context_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_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),
+            "extract-context",
+            "--chapter",
+            "12",
+            "--format",
+            "json",
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    assert int(exc.value.code or 0) == 0
+    assert called["script_name"] == "extract_chapter_context.py"
+    assert called["argv"] == [
+        "--project-root",
+        str(book_root),
+        "--chapter",
+        "12",
+        "--format",
+        "json",
+    ]
+
+
+def test_quality_trend_report_writes_to_book_root_when_input_is_workspace_root(tmp_path, monkeypatch):
+    _ensure_scripts_on_path()
+    import quality_trend_report as quality_trend_report_module
+
+    workspace_root = (tmp_path / "workspace").resolve()
+    book_root = (workspace_root / "凡人资本论").resolve()
+
+    (workspace_root / ".claude").mkdir(parents=True, exist_ok=True)
+    (workspace_root / ".claude" / ".webnovel-current-project").write_text(str(book_root), encoding="utf-8")
+
+    (book_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (book_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+
+    output_path = workspace_root / "report.md"
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "quality_trend_report",
+            "--project-root",
+            str(workspace_root),
+            "--limit",
+            "1",
+            "--output",
+            str(output_path),
+        ],
+    )
+
+    quality_trend_report_module.main()
+
+    assert output_path.is_file()
+    assert (book_root / ".webnovel" / "index.db").is_file()
+    assert not (workspace_root / ".webnovel" / "index.db").exists()

+ 257 - 0
.claude/scripts/data_modules/webnovel.py

@@ -0,0 +1,257 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+webnovel 统一入口(面向 skills / agents 的稳定 CLI)
+
+设计目标:
+- 只有一个入口命令,避免到处拼 `python -m data_modules.xxx ...` 导致参数位置/引号/路径炸裂。
+- 自动解析正确的 book project_root(包含 `.webnovel/state.json` 的目录)。
+- 所有写入类命令在解析到 project_root 后,统一前置 `--project-root` 传给具体模块。
+
+典型用法(推荐,不依赖 PYTHONPATH / 不要求 cd):
+  python "<SCRIPTS_DIR>/webnovel.py" where
+  python "<SCRIPTS_DIR>/webnovel.py" use D:\\wk\\xiaoshuo\\凡人资本论
+  python "<SCRIPTS_DIR>/webnovel.py" --project-root D:\\wk\\xiaoshuo index stats
+  python "<SCRIPTS_DIR>/webnovel.py" --project-root D:\\wk\\xiaoshuo state process-chapter --chapter 100 --data @payload.json
+  python "<SCRIPTS_DIR>/webnovel.py" --project-root D:\\wk\\xiaoshuo extract-context --chapter 100 --format json
+
+也支持(不推荐,容易踩 PYTHONPATH/cd/参数顺序坑):
+  python -m data_modules.webnovel where
+"""
+
+from __future__ import annotations
+
+import argparse
+import importlib
+import subprocess
+import sys
+from pathlib import Path
+from typing import Optional
+
+from runtime_compat import normalize_windows_path
+from project_locator import resolve_project_root, write_current_project_pointer, update_global_registry_current_project
+
+
+def _scripts_dir() -> Path:
+    # data_modules/webnovel.py -> data_modules -> scripts
+    return Path(__file__).resolve().parent.parent
+
+
+def _resolve_root(explicit_project_root: Optional[str]) -> Path:
+    # 允许显式传入工作区根目录或书项目根目录
+    raw = explicit_project_root
+    if raw:
+        return resolve_project_root(raw)
+    return resolve_project_root()
+
+
+def _strip_project_root_args(argv: list[str]) -> list[str]:
+    """
+    下游工具统一由本入口注入 `--project-root`,避免重复传参导致 argparse 报错/歧义。
+    """
+    out: list[str] = []
+    i = 0
+    while i < len(argv):
+        tok = argv[i]
+        if tok == "--project-root":
+            i += 2
+            continue
+        if tok.startswith("--project-root="):
+            i += 1
+            continue
+        out.append(tok)
+        i += 1
+    return out
+
+
+def _run_data_module(module: str, argv: list[str]) -> int:
+    """
+    Import `data_modules.<module>` and call its main(), while isolating sys.argv.
+    """
+    mod = importlib.import_module(f"data_modules.{module}")
+    main = getattr(mod, "main", None)
+    if not callable(main):
+        raise RuntimeError(f"data_modules.{module} 缺少可调用的 main()")
+
+    old_argv = sys.argv
+    try:
+        sys.argv = [f"data_modules.{module}"] + argv
+        try:
+            main()
+            return 0
+        except SystemExit as e:
+            return int(e.code or 0)
+    finally:
+        sys.argv = old_argv
+
+
+def _run_script(script_name: str, argv: list[str]) -> int:
+    """
+    Run a script under `.claude/scripts/` via a subprocess.
+
+    用途:兼容没有 main() 的脚本(例如 workflow_manager.py)。
+    """
+    script_path = _scripts_dir() / script_name
+    if not script_path.is_file():
+        raise FileNotFoundError(f"未找到脚本: {script_path}")
+    proc = subprocess.run([sys.executable, str(script_path), *argv])
+    return int(proc.returncode or 0)
+
+
+def cmd_where(args: argparse.Namespace) -> int:
+    root = _resolve_root(args.project_root)
+    print(str(root))
+    return 0
+
+
+def cmd_use(args: argparse.Namespace) -> int:
+    project_root = normalize_windows_path(args.project_root).expanduser()
+    try:
+        project_root = project_root.resolve()
+    except Exception:
+        project_root = project_root
+
+    workspace_root: Optional[Path] = None
+    if args.workspace_root:
+        workspace_root = normalize_windows_path(args.workspace_root).expanduser()
+        try:
+            workspace_root = workspace_root.resolve()
+        except Exception:
+            workspace_root = workspace_root
+
+    # 1) 写入工作区指针(若工作区内存在 `.claude/`)
+    pointer_file = write_current_project_pointer(project_root, workspace_root=workspace_root)
+    if pointer_file is not None:
+        print(f"workspace pointer: {pointer_file}")
+    else:
+        print("workspace pointer: (skipped)")
+
+    # 2) 写入用户级 registry(保证全局安装/空上下文可恢复)
+    reg_path = update_global_registry_current_project(workspace_root=workspace_root, project_root=project_root)
+    if reg_path is not None:
+        print(f"global registry: {reg_path}")
+    else:
+        print("global registry: (skipped)")
+
+    return 0
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="webnovel unified CLI")
+    parser.add_argument("--project-root", help="书项目根目录或工作区根目录(可选,默认自动检测)")
+
+    sub = parser.add_subparsers(dest="tool", required=True)
+
+    p_where = sub.add_parser("where", help="打印解析出的 project_root")
+    p_where.set_defaults(func=cmd_where)
+
+    p_use = sub.add_parser("use", help="绑定当前工作区使用的书项目(写入指针/registry)")
+    p_use.add_argument("project_root", help="书项目根目录(必须包含 .webnovel/state.json)")
+    p_use.add_argument("--workspace-root", help="工作区根目录(可选;默认由运行环境推断)")
+    p_use.set_defaults(func=cmd_use)
+
+    # Pass-through to data modules
+    p_index = sub.add_parser("index", help="转发到 index_manager")
+    p_index.add_argument("args", nargs=argparse.REMAINDER)
+
+    p_state = sub.add_parser("state", help="转发到 state_manager")
+    p_state.add_argument("args", nargs=argparse.REMAINDER)
+
+    p_rag = sub.add_parser("rag", help="转发到 rag_adapter")
+    p_rag.add_argument("args", nargs=argparse.REMAINDER)
+
+    p_style = sub.add_parser("style", help="转发到 style_sampler")
+    p_style.add_argument("args", nargs=argparse.REMAINDER)
+
+    p_entity = sub.add_parser("entity", help="转发到 entity_linker")
+    p_entity.add_argument("args", nargs=argparse.REMAINDER)
+
+    p_context = sub.add_parser("context", help="转发到 context_manager")
+    p_context.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)
+
+    # Pass-through to scripts
+    p_workflow = sub.add_parser("workflow", help="转发到 workflow_manager.py")
+    p_workflow.add_argument("args", nargs=argparse.REMAINDER)
+
+    p_status = sub.add_parser("status", help="转发到 status_reporter.py")
+    p_status.add_argument("args", nargs=argparse.REMAINDER)
+
+    p_update_state = sub.add_parser("update-state", help="转发到 update_state.py")
+    p_update_state.add_argument("args", nargs=argparse.REMAINDER)
+
+    p_backup = sub.add_parser("backup", help="转发到 backup_manager.py")
+    p_backup.add_argument("args", nargs=argparse.REMAINDER)
+
+    p_archive = sub.add_parser("archive", help="转发到 archive_manager.py")
+    p_archive.add_argument("args", nargs=argparse.REMAINDER)
+
+    p_init = sub.add_parser("init", help="转发到 init_project.py(初始化项目)")
+    p_init.add_argument("args", nargs=argparse.REMAINDER)
+
+    p_extract_context = sub.add_parser("extract-context", help="转发到 extract_chapter_context.py")
+    p_extract_context.add_argument("--chapter", type=int, required=True, help="目标章节号")
+    p_extract_context.add_argument("--format", choices=["text", "json"], default="text", help="输出格式")
+
+    # 兼容:允许 `--project-root` 出现在任意位置(减少 agents/skills 拼命令的出错率)
+    from .cli_args import normalize_global_project_root
+
+    argv = normalize_global_project_root(sys.argv[1:])
+    args = parser.parse_args(argv)
+
+    # where/use 直接执行
+    if hasattr(args, "func"):
+        code = int(args.func(args) or 0)
+        raise SystemExit(code)
+
+    tool = args.tool
+    rest = list(getattr(args, "args", []) or [])
+    # argparse.REMAINDER 可能以 `--` 开头占位,这里去掉
+    if rest[:1] == ["--"]:
+        rest = rest[1:]
+    rest = _strip_project_root_args(rest)
+
+    # init 是创建项目,不应该依赖/注入已存在 project_root
+    if tool == "init":
+        raise SystemExit(_run_script("init_project.py", rest))
+
+    # 其余工具:统一解析 project_root 后前置给下游
+    project_root = _resolve_root(args.project_root)
+    forward_args = ["--project-root", str(project_root)]
+
+    if tool == "index":
+        raise SystemExit(_run_data_module("index_manager", [*forward_args, *rest]))
+    if tool == "state":
+        raise SystemExit(_run_data_module("state_manager", [*forward_args, *rest]))
+    if tool == "rag":
+        raise SystemExit(_run_data_module("rag_adapter", [*forward_args, *rest]))
+    if tool == "style":
+        raise SystemExit(_run_data_module("style_sampler", [*forward_args, *rest]))
+    if tool == "entity":
+        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 == "migrate":
+        raise SystemExit(_run_data_module("migrate_state_to_sqlite", [*forward_args, *rest]))
+
+    if tool == "workflow":
+        raise SystemExit(_run_script("workflow_manager.py", [*forward_args, *rest]))
+    if tool == "status":
+        raise SystemExit(_run_script("status_reporter.py", [*forward_args, *rest]))
+    if tool == "update-state":
+        raise SystemExit(_run_script("update_state.py", [*forward_args, *rest]))
+    if tool == "backup":
+        raise SystemExit(_run_script("backup_manager.py", [*forward_args, *rest]))
+    if tool == "archive":
+        raise SystemExit(_run_script("archive_manager.py", [*forward_args, *rest]))
+    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))
+
+    raise SystemExit(2)
+
+
+if __name__ == "__main__":
+    main()

+ 10 - 15
.claude/scripts/extract_chapter_context.py

@@ -114,21 +114,12 @@ def _volume_num_for_chapter_from_state(project_root: Path, chapter_num: int) ->
 
 
 def find_project_root(start_path: Path | None = None) -> Path:
-    """Find project root containing `.webnovel` directory."""
-    if start_path is None:
-        start_path = Path.cwd()
-
-    search_paths = [
-        start_path,
-        start_path / "webnovel-project",
-        start_path.parent,
-    ]
+    """解析真实书项目根(包含 `.webnovel/state.json` 的目录)。"""
+    from project_locator import resolve_project_root
 
-    for path in search_paths:
-        if (path / ".webnovel").exists():
-            return path
-
-    raise FileNotFoundError("未找到 .webnovel 目录,请确认项目路径")
+    if start_path is None:
+        return resolve_project_root()
+    return resolve_project_root(str(start_path))
 
 
 def extract_chapter_outline(project_root: Path, chapter_num: int) -> str:
@@ -606,7 +597,11 @@ def main():
     args = parser.parse_args()
 
     try:
-        project_root = Path(args.project_root) if args.project_root else find_project_root()
+        project_root = (
+            find_project_root(Path(args.project_root))
+            if args.project_root
+            else find_project_root()
+        )
         payload = build_chapter_context_payload(project_root, args.chapter)
 
         if args.format == "json":

+ 28 - 0
.claude/scripts/init_project.py

@@ -657,6 +657,29 @@ def init_project(
         ),
     )
 
+    # 生成环境变量模板(不写入真实密钥)
+    _write_text_if_missing(
+        project_path / ".env.example",
+        "\n".join(
+            [
+                "# Webnovel Writer 配置示例(复制为 .env 后填写)",
+                "# 注意:请勿将包含真实 API_KEY 的 .env 提交到版本库。",
+                "",
+                "# Embedding",
+                "EMBED_BASE_URL=https://api-inference.modelscope.cn/v1",
+                "EMBED_MODEL=Qwen/Qwen3-Embedding-8B",
+                "EMBED_API_KEY=",
+                "",
+                "# Rerank",
+                "RERANK_BASE_URL=https://api.jina.ai/v1",
+                "RERANK_MODEL=jina-reranker-v3",
+                "RERANK_API_KEY=",
+                "",
+            ]
+        )
+        + "\n",
+    )
+
     # Git 初始化(仅当项目目录内尚无 .git 且 Git 可用)
     git_dir = project_path / ".git"
     if not git_dir.exists():
@@ -676,6 +699,11 @@ __pycache__/
 *.py[cod]
 *.so
 
+# Env (keep .env.example)
+.env
+.env.*
+!.env.example
+
 # Temporary files
 *.tmp
 *.bak

+ 261 - 8
.claude/scripts/project_locator.py

@@ -12,14 +12,27 @@ These helpers provide a single, consistent way to locate the active project root
 
 from __future__ import annotations
 
+import json
 import os
+from datetime import datetime
 from pathlib import Path
 from typing import Iterable, Optional
 
+from runtime_compat import normalize_windows_path
+
 
 DEFAULT_PROJECT_DIR_NAMES: tuple[str, ...] = ("webnovel-project",)
 CURRENT_PROJECT_POINTER_REL: Path = Path(".claude") / ".webnovel-current-project"
 
+# 用户级全局映射(当 skills/agents 安装在 ~/.claude 时,项目目录可能在任意盘符)
+# 该文件用于在“空上下文 + CWD 不在项目内”的情况下仍能定位到正确 project_root。
+GLOBAL_REGISTRY_REL: Path = Path("webnovel-writer") / "workspaces.json"
+
+# Claude Code 常见环境变量(存在时优先作为“工作区根目录”提示)
+ENV_CLAUDE_PROJECT_DIR = "CLAUDE_PROJECT_DIR"
+ENV_CLAUDE_HOME = "CLAUDE_HOME"
+ENV_WEBNOVEL_CLAUDE_HOME = "WEBNOVEL_CLAUDE_HOME"
+
 
 def _find_git_root(cwd: Path) -> Optional[Path]:
     """Return nearest git root for cwd, if any."""
@@ -29,6 +42,200 @@ def _find_git_root(cwd: Path) -> Optional[Path]:
     return None
 
 
+def _now_iso() -> str:
+    return datetime.now().isoformat(timespec="seconds")
+
+
+def _normcase_path_key(p: Path) -> str:
+    """
+    生成稳定的路径 key(Windows 下大小写/分隔符不敏感)。
+
+    注意:key 仅用于映射表索引,实际路径仍以原始绝对路径字符串存储。
+    """
+    try:
+        resolved = p.expanduser().resolve()
+    except Exception:
+        resolved = p.expanduser()
+    return os.path.normcase(str(resolved))
+
+
+def _get_user_claude_root() -> Path:
+    raw = os.environ.get(ENV_WEBNOVEL_CLAUDE_HOME) or os.environ.get(ENV_CLAUDE_HOME)
+    if raw:
+        try:
+            return normalize_windows_path(raw).expanduser().resolve()
+        except Exception:
+            return normalize_windows_path(raw).expanduser()
+    return (Path.home() / ".claude").resolve()
+
+
+def _global_registry_path() -> Path:
+    return _get_user_claude_root() / GLOBAL_REGISTRY_REL
+
+
+def _default_registry() -> dict:
+    return {
+        "schema_version": 1,
+        "workspaces": {},
+        "last_used_project_root": "",
+        "updated_at": _now_iso(),
+    }
+
+
+def _load_global_registry(path: Path) -> dict:
+    if not path.is_file():
+        return _default_registry()
+    try:
+        data = json.loads(path.read_text(encoding="utf-8") or "{}")
+    except Exception:
+        return _default_registry()
+    if not isinstance(data, dict):
+        return _default_registry()
+
+    if data.get("schema_version") != 1:
+        data["schema_version"] = 1
+    if not isinstance(data.get("workspaces"), dict):
+        data["workspaces"] = {}
+    if not isinstance(data.get("last_used_project_root"), str):
+        data["last_used_project_root"] = ""
+    if not isinstance(data.get("updated_at"), str):
+        data["updated_at"] = _now_iso()
+    return data
+
+
+def _save_global_registry(path: Path, data: dict) -> None:
+    # 写入是 best-effort:用户目录权限/只读盘符等情况不应阻断主流程。
+    try:
+        from security_utils import atomic_write_json
+
+        data["updated_at"] = _now_iso()
+        atomic_write_json(path, data, backup=False)
+    except Exception:
+        # 非阻断
+        return
+
+
+def _resolve_project_root_from_global_registry(
+    base: Path,
+    *,
+    workspace_hint: Optional[Path] = None,
+    allow_last_used_fallback: bool = False,
+) -> Optional[Path]:
+    """
+    从用户级 registry 中解析 project_root。
+
+    安全策略:
+    - 优先使用 workspace_hint / CLAUDE_PROJECT_DIR 提示做匹配。
+    - 默认不使用 last_used 兜底,避免在“完全无上下文”时误命中错误项目。
+    """
+    reg_path = _global_registry_path()
+    reg = _load_global_registry(reg_path)
+    workspaces = reg.get("workspaces") or {}
+    if not isinstance(workspaces, dict) or not workspaces:
+        return None
+
+    hints: list[Path] = []
+    env_ws = os.environ.get(ENV_CLAUDE_PROJECT_DIR)
+    if env_ws:
+        hints.append(normalize_windows_path(env_ws).expanduser())
+    if workspace_hint is not None:
+        hints.append(workspace_hint)
+    hints.append(base)
+
+    # 1) 精确匹配
+    for hint in hints:
+        key = _normcase_path_key(hint)
+        entry = workspaces.get(key)
+        if isinstance(entry, dict):
+            raw = entry.get("current_project_root")
+            if isinstance(raw, str) and raw.strip():
+                target = normalize_windows_path(raw).expanduser()
+                if not target.is_absolute():
+                    continue
+                if _is_project_root(target):
+                    return target.resolve()
+
+    # 2) 前缀匹配(从 workspace 子目录运行时)
+    for hint in hints:
+        hint_key = _normcase_path_key(hint)
+        best_key: Optional[str] = None
+        best_len = -1
+        for ws_key in workspaces.keys():
+            if not isinstance(ws_key, str) or not ws_key:
+                continue
+            ws_key_norm = os.path.normcase(ws_key)
+            if hint_key == ws_key_norm or hint_key.startswith(ws_key_norm.rstrip("\\") + "\\"):
+                if len(ws_key_norm) > best_len:
+                    best_key = ws_key
+                    best_len = len(ws_key_norm)
+        if best_key:
+            entry = workspaces.get(best_key)
+            if isinstance(entry, dict):
+                raw = entry.get("current_project_root")
+                if isinstance(raw, str) and raw.strip():
+                    target = normalize_windows_path(raw).expanduser()
+                    if target.is_absolute() and _is_project_root(target):
+                        return target.resolve()
+
+    # 3) last_used(可选,默认关闭)
+    if allow_last_used_fallback:
+        raw = reg.get("last_used_project_root")
+        if isinstance(raw, str) and raw.strip():
+            target = normalize_windows_path(raw).expanduser()
+            if target.is_absolute() and _is_project_root(target):
+                return target.resolve()
+
+    return None
+
+
+def update_global_registry_current_project(
+    *,
+    workspace_root: Optional[Path],
+    project_root: Path,
+) -> Optional[Path]:
+    """
+    更新用户级 registry:workspace -> current_project_root 映射。
+
+    返回:registry 文件路径(写入失败则返回 None)。
+    """
+    root = normalize_windows_path(project_root).expanduser()
+    try:
+        root = root.resolve()
+    except Exception:
+        root = root
+    if not _is_project_root(root):
+        raise FileNotFoundError(f"Not a webnovel project root (missing .webnovel/state.json): {root}")
+
+    ws = workspace_root
+    if ws is None:
+        env_ws = os.environ.get(ENV_CLAUDE_PROJECT_DIR)
+        if env_ws:
+            ws = normalize_windows_path(env_ws).expanduser()
+    if ws is None:
+        return None
+
+    try:
+        ws = ws.expanduser().resolve()
+    except Exception:
+        ws = ws.expanduser()
+
+    reg_path = _global_registry_path()
+    reg = _load_global_registry(reg_path)
+    workspaces = reg.get("workspaces")
+    if not isinstance(workspaces, dict):
+        workspaces = {}
+        reg["workspaces"] = workspaces
+
+    workspaces[_normcase_path_key(ws)] = {
+        "workspace_root": str(ws),
+        "current_project_root": str(root),
+        "updated_at": _now_iso(),
+    }
+    reg["last_used_project_root"] = str(root)
+    _save_global_registry(reg_path, reg)
+    return reg_path
+
+
 def _candidate_roots(cwd: Path, *, stop_at: Optional[Path] = None) -> Iterable[Path]:
     yield cwd
     for name in DEFAULT_PROJECT_DIR_NAMES:
@@ -68,7 +275,7 @@ def _resolve_project_root_from_pointer(cwd: Path, *, stop_at: Optional[Path] = N
         raw = pointer_file.read_text(encoding="utf-8").strip()
         if not raw:
             continue
-        target = Path(raw).expanduser()
+        target = normalize_windows_path(raw).expanduser()
         if not target.is_absolute():
             target = (pointer_file.parent / target).resolve()
         if _is_project_root(target):
@@ -90,7 +297,7 @@ def write_current_project_pointer(project_root: Path, *, workspace_root: Optiona
 
     If no workspace root with `.claude/` can be found, returns None (non-fatal).
     """
-    root = Path(project_root).expanduser().resolve()
+    root = normalize_windows_path(project_root).expanduser().resolve()
     if not _is_project_root(root):
         raise FileNotFoundError(f"Not a webnovel project root (missing .webnovel/state.json): {root}")
 
@@ -98,11 +305,28 @@ def write_current_project_pointer(project_root: Path, *, workspace_root: Optiona
     if ws_root is None:
         ws_root = _find_workspace_root_with_claude(Path.cwd().resolve())
     if ws_root is None:
-        return None
+        # 兜底:若无法找到 `.claude/`,将项目父目录视为“工作区”候选,
+        # 仅用于写入用户级 registry(不创建 `.claude/` 目录,不写 pointer 文件)。
+        ws_root = root.parent if root.parent != root else None
+    # 注意:ws_root 可能为 None(例如全局安装的 skills/agents,工作区内没有 `.claude/`)。
+    # 这类情况仍然需要写入用户级 registry,以支持后续“空上下文”定位。
+
+    pointer_file: Optional[Path] = None
+    if ws_root is not None:
+        # 仅当工作区内已经存在 `.claude/` 时才写入指针,避免在任意目录下“凭空创建 .claude/”。
+        if (ws_root / ".claude").is_dir():
+            try:
+                pointer_file = ws_root / CURRENT_PROJECT_POINTER_REL
+                pointer_file.write_text(str(root), encoding="utf-8")
+            except Exception:
+                pointer_file = None
+
+    # best-effort 更新用户级 registry(不阻断)
+    try:
+        update_global_registry_current_project(workspace_root=ws_root, project_root=root)
+    except Exception:
+        pass
 
-    pointer_file = ws_root / CURRENT_PROJECT_POINTER_REL
-    pointer_file.parent.mkdir(parents=True, exist_ok=True)
-    pointer_file.write_text(str(root), encoding="utf-8")
     return pointer_file
 
 
@@ -123,14 +347,31 @@ def resolve_project_root(explicit_project_root: Optional[str] = None, *, cwd: Op
         FileNotFoundError: if no valid project root can be found.
     """
     if explicit_project_root:
-        root = Path(explicit_project_root).expanduser().resolve()
+        root = normalize_windows_path(explicit_project_root).expanduser().resolve()
         if _is_project_root(root):
             return root
+
+        # 兼容:显式传入“工作区根目录”(含 `.claude/.webnovel-current-project` 指针)
+        # 例如:D:\wk\xiaoshuo 不是项目根,但其指针指向 D:\wk\xiaoshuo\<书名>
+        pointer_root = _resolve_project_root_from_pointer(root, stop_at=_find_git_root(root))
+        if pointer_root is not None:
+            return pointer_root
+
+        # 兼容:显式传入“工作区根目录”但其 `.claude/` 在用户目录(全局安装)时,
+        # workspace 内部可能没有指针文件。此时从用户级 registry 查找。
+        reg_root = _resolve_project_root_from_global_registry(
+            root,
+            workspace_hint=root,
+            allow_last_used_fallback=False,
+        )
+        if reg_root is not None:
+            return reg_root
+
         raise FileNotFoundError(f"Not a webnovel project root (missing .webnovel/state.json): {root}")
 
     env_root = os.environ.get("WEBNOVEL_PROJECT_ROOT")
     if env_root:
-        root = Path(env_root).expanduser().resolve()
+        root = normalize_windows_path(env_root).expanduser().resolve()
         if _is_project_root(root):
             return root
         raise FileNotFoundError(f"WEBNOVEL_PROJECT_ROOT is set but invalid (missing .webnovel/state.json): {root}")
@@ -143,6 +384,18 @@ def resolve_project_root(explicit_project_root: Optional[str] = None, *, cwd: Op
     if pointer_root is not None:
         return pointer_root
 
+    # 用户级 registry fallback(仅在“有上下文提示”时启用,避免误命中)
+    # - 若 CLAUDE_PROJECT_DIR 存在:认为 Claude Code 提供了工作区上下文
+    # - 否则仅在 base 位于某个已记录 workspace 内时启用(前缀匹配)
+    allow_last_used = bool(os.environ.get(ENV_CLAUDE_PROJECT_DIR))
+    reg_root = _resolve_project_root_from_global_registry(
+        base,
+        workspace_hint=None,
+        allow_last_used_fallback=allow_last_used,
+    )
+    if reg_root is not None:
+        return reg_root
+
     for candidate in _candidate_roots(base, stop_at=git_root):
         if _is_project_root(candidate):
             return candidate.resolve()

+ 2 - 1
.claude/scripts/quality_trend_report.py

@@ -213,7 +213,8 @@ def main() -> None:
     args = parser.parse_args()
 
     if args.project_root:
-        project_root = Path(args.project_root).expanduser().resolve()
+        # 允许传入“工作区根目录”,统一解析到真正的 book project_root
+        project_root = resolve_project_root(args.project_root)
     else:
         project_root = resolve_project_root()
 

+ 26 - 1
.claude/scripts/run_tests.ps1

@@ -21,12 +21,37 @@ $env:TMP = $tmpRoot
 $env:TEMP = $tmpRoot
 $env:PYTHONPATH = ".claude/scripts"
 
-$baseTemp = Join-Path $tmpRoot ("run-" + $Mode)
+# 避免 Windows 下 basetemp 目录因权限/残留锁导致 rm_rf 失败(会让所有用例在 setup 阶段直接报错)。
+$runId = Get-Date -Format "yyyyMMdd_HHmmssfff"
+$baseTemp = Join-Path $tmpRoot ("run-" + $Mode + "-" + $runId)
 
 Write-Host "ProjectRoot: $ProjectRoot"
 Write-Host "TMP/TEMP: $tmpRoot"
 Write-Host "Mode: $Mode"
 
+# 预检:某些 Windows Python 发行版(尤其 WindowsApps shim)在 tempfile.mkdtemp 时会创建“不可访问目录”,
+# 会导致 pytest 在创建/清理临时目录阶段直接 WinError 5。
+@'
+import tempfile
+from pathlib import Path
+import sys
+
+try:
+    d = Path(tempfile.mkdtemp(prefix="webnovel_writer_pytest_"))
+    # 既要能列目录,也要能写文件;否则 pytest 必挂。
+    list(d.iterdir())
+    (d / "probe.txt").write_text("ok", encoding="utf-8")
+except Exception as exc:
+    print(f"PYTEST_TMPDIR_PRECHECK_FAILED: {type(exc).__name__}: {exc}", file=sys.stderr)
+    raise
+'@ | python - 2>$null
+if ($LASTEXITCODE -ne 0) {
+    Write-Host ""
+    Write-Host "❌ Python 临时目录预检失败(常见原因:WindowsApps 的 python.exe shim / 权限异常)"
+    Write-Host "建议:改用标准 Python(python.org 安装版)或用 uv/uvx 提供的 Python 运行测试。"
+    exit 1
+}
+
 if ($Mode -eq "smoke") {
     python -m pytest -q `
         .claude/scripts/data_modules/tests/test_extract_chapter_context.py `

+ 39 - 0
.claude/scripts/runtime_compat.py

@@ -7,7 +7,10 @@ Runtime compatibility helpers.
 from __future__ import annotations
 
 import os
+import re
 import sys
+from pathlib import Path
+from typing import Union
 
 
 def enable_windows_utf8_stdio(*, skip_in_pytest: bool = False) -> bool:
@@ -37,3 +40,39 @@ def enable_windows_utf8_stdio(*, skip_in_pytest: bool = False) -> bool:
     except Exception:
         return False
 
+
+_WIN_POSIX_DRIVE_RE = re.compile(r"^/(?P<drive>[a-zA-Z])/(?P<rest>.*)$")
+_WIN_WSL_MNT_DRIVE_RE = re.compile(r"^/mnt/(?P<drive>[a-zA-Z])/(?P<rest>.*)$")
+
+
+def normalize_windows_path(value: Union[str, Path]) -> Path:
+    """
+    将 Windows 上常见的 POSIX 风格路径规范化为 Windows 盘符路径。
+
+    典型来源:
+    - Git Bash / MSYS:  /d/desktop/...  => D:/desktop/...
+    - WSL:             /mnt/d/desktop/... => D:/desktop/...
+
+    非 Windows 平台直接返回 Path(value)。
+    """
+    if sys.platform != "win32":
+        return Path(value)
+
+    raw = str(value).strip()
+    if not raw:
+        return Path(raw)
+
+    m = _WIN_WSL_MNT_DRIVE_RE.match(raw)
+    if m:
+        drive = m.group("drive").upper()
+        rest = m.group("rest")
+        return Path(f"{drive}:/{rest}")
+
+    m = _WIN_POSIX_DRIVE_RE.match(raw)
+    if m:
+        drive = m.group("drive").upper()
+        rest = m.group("rest")
+        return Path(f"{drive}:/{rest}")
+
+    return Path(value)
+

+ 6 - 2
.claude/scripts/security_utils.py

@@ -158,8 +158,12 @@ def create_secure_directory(path: str, mode: int = 0o700) -> Path:
     """
     path_obj = Path(path)
 
-    # 创建目录(设置安全权限)
-    os.makedirs(path, mode=mode, exist_ok=True)
+    # Windows 上传入 mode 会触发不可预期的 ACL 行为(实测会导致目录创建后立刻无法访问)。
+    # 因此在 Windows 下不传 mode,保持默认继承权限;在类 Unix 系统才使用 mode。
+    if os.name == 'nt':
+        os.makedirs(path, exist_ok=True)
+    else:
+        os.makedirs(path, mode=mode, exist_ok=True)
 
     # 双重保险:显式设置权限(某些系统可能忽略makedirs的mode参数)
     if os.name != 'nt':  # Unix系统(Linux/macOS)

+ 6 - 7
.claude/scripts/status_reporter.py

@@ -1167,13 +1167,12 @@ def main():
 
     args = parser.parse_args()
 
-    # 解析项目根目录(支持从仓库根目录运行)
-    project_root = args.project_root
-    if project_root == '.' and not (Path('.') / '.webnovel' / 'state.json').exists():
-        try:
-            project_root = str(resolve_project_root())
-        except FileNotFoundError:
-            project_root = args.project_root
+    # 解析项目根目录(允许传入“工作区根目录”,统一解析到真正的 book project_root)
+    try:
+        project_root = str(resolve_project_root(args.project_root))
+    except FileNotFoundError as exc:
+        print(f"❌ 无法定位项目根目录(需要包含 .webnovel/state.json): {exc}", file=sys.stderr)
+        sys.exit(1)
 
     # 创建报告生成器
     reporter = StatusReporter(project_root)

+ 36 - 0
.claude/scripts/webnovel.py

@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+webnovel 统一入口脚本(无须 `cd`)
+
+用法示例:
+  python "<SCRIPTS_DIR>/webnovel.py" where
+  python "<SCRIPTS_DIR>/webnovel.py" index stats
+
+说明:
+- 该脚本仅负责把 `.claude/scripts` 加入 sys.path,然后转发到 `data_modules.webnovel`。
+- 适配 skills/agents 在项目级或用户级(~/.claude)安装时的调用方式。
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+from runtime_compat import enable_windows_utf8_stdio
+
+
+def main() -> None:
+    scripts_dir = Path(__file__).resolve().parent
+    sys.path.insert(0, str(scripts_dir))
+
+    # 延迟导入,避免 sys.path 未就绪
+    from data_modules.webnovel import main as _main
+
+    _main()
+
+
+if __name__ == "__main__":
+    enable_windows_utf8_stdio(skip_in_pytest=True)
+    main()
+

+ 6 - 4
.claude/scripts/workflow_manager.py

@@ -21,7 +21,7 @@ from typing import Any, Dict, Optional
 
 from chapter_paths import default_chapter_draft_path, find_chapter_file
 from project_locator import resolve_project_root
-from runtime_compat import enable_windows_utf8_stdio
+from runtime_compat import enable_windows_utf8_stdio, normalize_windows_path
 from security_utils import atomic_write_json, create_secure_directory
 
 
@@ -54,7 +54,8 @@ def find_project_root(override: Optional[Path] = None) -> Path:
         override: If provided, use this path directly instead of auto-detecting.
     """
     if override is not None:
-        return Path(override).resolve()
+        # 允许传入“工作区根目录”,统一解析到真正的 book project_root(必须包含 .webnovel/state.json)
+        return resolve_project_root(str(override))
     return resolve_project_root()
 
 
@@ -706,7 +707,8 @@ def save_state(state):
 def get_pending_steps(command):
     """Get command pending step list."""
     if command == "webnovel-write":
-        return ["Step 1", "Step 1.5", "Step 2A", "Step 2B", "Step 3", "Step 4", "Step 5", "Step 6"]
+        # 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"]
     if command == "webnovel-review":
         return ["Step 1", "Step 2", "Step 3", "Step 4", "Step 5", "Step 6", "Step 7", "Step 8"]
     return []
@@ -777,7 +779,7 @@ if __name__ == "__main__":
     # Set global project root if provided (support both before/after subcommand).
     project_root_arg = getattr(args, "project_root", None) or getattr(args, "global_project_root", None)
     if project_root_arg:
-        _cli_project_root = Path(project_root_arg)
+        _cli_project_root = normalize_windows_path(project_root_arg)
 
     if args.action == "start-task":
         start_task(args.command, {"chapter_num": args.chapter})

+ 24 - 2
.claude/skills/webnovel-init/SKILL.md

@@ -125,9 +125,31 @@ allowed-tools: Read Write Edit Grep Bash Task AskUserQuestion WebSearch WebFetch
 
 ### Step 0:预检与上下文加载
 
+环境设置(bash 命令执行前):
+```bash
+export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
+
+if [ -d "${WORKSPACE_ROOT}/.claude/scripts" ]; then
+  export SCRIPTS_DIR="${WORKSPACE_ROOT}/.claude/scripts"
+elif [ -d "${WORKSPACE_ROOT}/../.claude/scripts" ]; then
+  export SCRIPTS_DIR="${WORKSPACE_ROOT}/../.claude/scripts"
+elif [ -d "${HOME}/.claude/scripts" ]; then
+  export SCRIPTS_DIR="${HOME}/.claude/scripts"
+elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
+  export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
+else
+  echo "ERROR: 未找到 scripts 目录" >&2
+  exit 1
+fi
+```
+
 必须做:
 - 确认当前目录可写。
-- 确认插件脚本存在:`.claude/scripts/init_project.py`。
+- 解析脚本目录并确认入口存在(优先工作区,其次用户目录,其次插件目录):
+  - 优先级:`${WORKSPACE_ROOT}/.claude/scripts` → `${HOME}/.claude/scripts` → `${CLAUDE_PLUGIN_ROOT}/scripts`
+  - 入口脚本:`${SCRIPTS_DIR}/webnovel.py`
+- 建议先打印解析结果,避免写到错误目录:
+  - `python "${SCRIPTS_DIR}/webnovel.py" where --project-root "${WORKSPACE_ROOT}"`
 - 加载最小参考:
   - `references/system-data-flow.md`(用于校对 init 产物与 plan/write 输入链路)
   - `references/genre-tropes.md`
@@ -317,7 +339,7 @@ allowed-tools: Read Write Edit Grep Bash Task AskUserQuestion WebSearch WebFetch
 ### 1) 运行初始化脚本
 
 ```bash
-python "${CLAUDE_PLUGIN_ROOT}/scripts/init_project.py" \
+python "${SCRIPTS_DIR}/webnovel.py" init \
   "{project_root}" \
   "{title}" \
   "{genre}" \

+ 44 - 12
.claude/skills/webnovel-plan/SKILL.md

@@ -9,8 +9,41 @@ Purpose: refine 总纲 into volume + chapter outlines. Do not redesign the globa
 Setting policy: 先基于 init 产出的总纲+世界观补齐设定集基线;再在卷纲完成后,直接对现有设定集做增量补充。
 
 ## Project Root Guard
-- Must run inside a project containing `.webnovel/state.json`.
-- If missing, ask for the project path and `cd` into it.
+- Claude Code 的“工作区根目录”不一定等于“书项目根目录”。常见结构:工作区为 `D:\wk\xiaoshuo`,书项目为 `D:\wk\xiaoshuo\凡人资本论`。
+- 必须先解析 `PROJECT_ROOT` 为真实书项目根(必须包含 `.webnovel/state.json`),后续所有读写路径都以该目录为准。
+
+环境设置(bash 命令执行前):
+```bash
+export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
+
+if [ -d "${WORKSPACE_ROOT}/.claude/skills/webnovel-plan" ]; then
+  export SKILL_ROOT="${WORKSPACE_ROOT}/.claude/skills/webnovel-plan"
+elif [ -d "${WORKSPACE_ROOT}/../.claude/skills/webnovel-plan" ]; then
+  export SKILL_ROOT="${WORKSPACE_ROOT}/../.claude/skills/webnovel-plan"
+elif [ -d "${HOME}/.claude/skills/webnovel-plan" ]; then
+  export SKILL_ROOT="${HOME}/.claude/skills/webnovel-plan"
+elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-plan" ]; then
+  export SKILL_ROOT="${CLAUDE_PLUGIN_ROOT}/skills/webnovel-plan"
+else
+  echo "ERROR: 未找到 webnovel-plan skill 目录" >&2
+  exit 1
+fi
+
+if [ -d "${WORKSPACE_ROOT}/.claude/scripts" ]; then
+  export SCRIPTS_DIR="${WORKSPACE_ROOT}/.claude/scripts"
+elif [ -d "${WORKSPACE_ROOT}/../.claude/scripts" ]; then
+  export SCRIPTS_DIR="${WORKSPACE_ROOT}/../.claude/scripts"
+elif [ -d "${HOME}/.claude/scripts" ]; then
+  export SCRIPTS_DIR="${HOME}/.claude/scripts"
+elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
+  export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
+else
+  echo "ERROR: 未找到 scripts 目录" >&2
+  exit 1
+fi
+
+export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
+```
 
 ## References(按步骤导航)
 
@@ -89,7 +122,7 @@ If 总纲缺少卷名/章节范围/核心冲突/卷末高潮,先补问并更
 
 Load template:
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/templates/output/大纲-卷节拍表.md"
+cat "${SKILL_ROOT}/../../templates/output/大纲-卷节拍表.md"
 ```
 
 Must satisfy (hard requirements):
@@ -111,18 +144,18 @@ Completion criteria:
 ## 5) Generate volume skeleton
 Load genre profile and apply standards:
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/references/genre-profiles.md"
-cat "${CLAUDE_PLUGIN_ROOT}/references/shared/strand-weave-pattern.md"
+cat "${SKILL_ROOT}/../../references/genre-profiles.md"
+cat "${SKILL_ROOT}/../../references/shared/strand-weave-pattern.md"
 ```
 
 Optional (only if爽点结构需要细化):
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/references/shared/cool-points-guide.md"
+cat "${SKILL_ROOT}/../../references/shared/cool-points-guide.md"
 ```
 
 Optional (only if需要补强卷级冲突链与强度分层):
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-plan/references/outlining/conflict-design.md"
+cat "${SKILL_ROOT}/references/outlining/conflict-design.md"
 ```
 
 Load beat sheet (must exist):
@@ -154,7 +187,7 @@ Based on genre profile, distribute chapters:
 
 For 电竞/直播文/克苏鲁, apply dedicated volume pacing template:
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-plan/references/outlining/genre-volume-pacing.md"
+cat "${SKILL_ROOT}/references/outlining/genre-volume-pacing.md"
 ```
 
 ### 爽点密度规划策略
@@ -224,12 +257,12 @@ Batching rule:
 
 Optional (only if需要钩子/节奏细分):
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/references/reading-power-taxonomy.md"
+cat "${SKILL_ROOT}/../../references/reading-power-taxonomy.md"
 ```
 
 Optional (only if需要章节微结构/标题策略细化):
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-plan/references/outlining/chapter-planning.md"
+cat "${SKILL_ROOT}/references/outlining/chapter-planning.md"
 ```
 
 ### Chapter generation strategy
@@ -367,8 +400,7 @@ Every chapter must have:
 
 Update state (include chapters range):
 ```bash
-python "${CLAUDE_PLUGIN_ROOT}/scripts/update_state.py" \
-  --project-root "$PROJECT_ROOT" \
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" update-state \
   --volume-planned {volume_id} \
   --chapters-range "{start}-{end}"
 ```

+ 41 - 9
.claude/skills/webnovel-query/SKILL.md

@@ -8,11 +8,43 @@ allowed-tools: Read Grep Bash AskUserQuestion
 
 ## Project Root Guard(必须先确认)
 
-- 必须在项目根目录执行(需存在 `.webnovel/state.json`)
-- 若当前目录不存在该文件,先询问用户项目路径并 `cd` 进入
-- 进入后设置变量:`$PROJECT_ROOT = (Resolve-Path ".").Path`
+- Claude Code 的“工作区根目录”不一定等于“书项目根目录”。常见结构:工作区为 `D:\wk\xiaoshuo`,书项目为 `D:\wk\xiaoshuo\凡人资本论`。
+- 必须先解析真实书项目根(必须包含 `.webnovel/state.json`),后续所有读写路径都以该目录为准。
 - **禁止**在 `.claude/` 下读取或写入项目文件
 
+环境设置(bash 命令执行前):
+```bash
+export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
+
+if [ -d "${WORKSPACE_ROOT}/.claude/skills/webnovel-query" ]; then
+  export SKILL_ROOT="${WORKSPACE_ROOT}/.claude/skills/webnovel-query"
+elif [ -d "${WORKSPACE_ROOT}/../.claude/skills/webnovel-query" ]; then
+  export SKILL_ROOT="${WORKSPACE_ROOT}/../.claude/skills/webnovel-query"
+elif [ -d "${HOME}/.claude/skills/webnovel-query" ]; then
+  export SKILL_ROOT="${HOME}/.claude/skills/webnovel-query"
+elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-query" ]; then
+  export SKILL_ROOT="${CLAUDE_PLUGIN_ROOT}/skills/webnovel-query"
+else
+  echo "ERROR: 未找到 webnovel-query skill 目录" >&2
+  exit 1
+fi
+
+if [ -d "${WORKSPACE_ROOT}/.claude/scripts" ]; then
+  export SCRIPTS_DIR="${WORKSPACE_ROOT}/.claude/scripts"
+elif [ -d "${WORKSPACE_ROOT}/../.claude/scripts" ]; then
+  export SCRIPTS_DIR="${WORKSPACE_ROOT}/../.claude/scripts"
+elif [ -d "${HOME}/.claude/scripts" ]; then
+  export SCRIPTS_DIR="${HOME}/.claude/scripts"
+elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
+  export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
+else
+  echo "ERROR: 未找到 scripts 目录" >&2
+  exit 1
+fi
+
+export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
+```
+
 ## Workflow Checklist
 
 Copy and track progress:
@@ -60,22 +92,22 @@ Do not load two or more L2 files unless the user request clearly spans multiple
 
 **所有查询必须执行**:
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-query/references/system-data-flow.md"
+cat "${SKILL_ROOT}/references/system-data-flow.md"
 ```
 
 **伏笔查询额外执行**:
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-query/references/advanced/foreshadowing.md"
+cat "${SKILL_ROOT}/references/advanced/foreshadowing.md"
 ```
 
 **节奏查询额外执行**:
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/references/shared/strand-weave-pattern.md"
+cat "${SKILL_ROOT}/../../references/shared/strand-weave-pattern.md"
 ```
 
 **标签格式查询额外执行**:
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-query/references/tag-specification.md"
+cat "${SKILL_ROOT}/references/tag-specification.md"
 ```
 
 ## Step 3: 加载项目数据
@@ -125,7 +157,7 @@ cat "$PROJECT_ROOT/.webnovel/state.json"
 
 **快速分析**:
 ```bash
-python "${CLAUDE_PLUGIN_ROOT}/scripts/status_reporter.py" --focus urgency --project-root "$PROJECT_ROOT"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" status --focus urgency
 ```
 
 ### 金手指状态
@@ -142,7 +174,7 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/status_reporter.py" --focus urgency --proj
 
 **快速分析**:
 ```bash
-python "${CLAUDE_PLUGIN_ROOT}/scripts/status_reporter.py" --focus strand --project-root "$PROJECT_ROOT"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" status --focus strand
 ```
 
 **检查警告**:

+ 9 - 9
.claude/skills/webnovel-query/references/system-data-flow.md

@@ -261,10 +261,10 @@ cat .webnovel/state.json | jq '.progress'
 <input>v5.1: 查询实体(SQL)</input>
 <output>
 ```bash
-python -m data_modules.index_manager get-entity --id "xiaoyan" --project-root "."
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" index get-entity --id "xiaoyan"
 # 输出: {"id": "xiaoyan", "type": "角色", "canonical_name": "萧炎", ...}
 
-python -m data_modules.index_manager get-core-entities --project-root "."
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" index get-core-entities
 # 输出: 所有核心实体(主角 + tier=核心/重要)
 ```
 </output>
@@ -274,7 +274,7 @@ python -m data_modules.index_manager get-core-entities --project-root "."
 <input>v5.1: 按别名查找实体(一对多)</input>
 <output>
 ```bash
-python -m data_modules.index_manager get-by-alias --alias "天云宗" --project-root "."
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" index get-by-alias --alias "天云宗"
 # 输出: [{"id": "loc_tianyunzong", "type": "地点"}, {"id": "faction_tianyunzong", "type": "势力"}]
 ```
 </output>
@@ -284,7 +284,7 @@ python -m data_modules.index_manager get-by-alias --alias "天云宗" --project-
 <input>v5.1: 查询状态变化</input>
 <output>
 ```bash
-python -m data_modules.index_manager get-state-changes --entity "xiaoyan" --limit 10 --project-root "."
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" index get-state-changes --entity "xiaoyan" --limit 10
 # 输出: [{entity_id, field, old_value, new_value, reason, chapter}, ...]
 ```
 </output>
@@ -294,7 +294,7 @@ python -m data_modules.index_manager get-state-changes --entity "xiaoyan" --limi
 <input>v5.1: 查询关系</input>
 <output>
 ```bash
-python -m data_modules.index_manager get-relationships --entity "xiaoyan" --project-root "."
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" index get-relationships --entity "xiaoyan"
 # 输出: [{from_entity, to_entity, type, description, chapter}, ...]
 ```
 </output>
@@ -304,7 +304,7 @@ python -m data_modules.index_manager get-relationships --entity "xiaoyan" --proj
 <input>检查伏笔紧急度</input>
 <output>
 ```bash
-python "${CLAUDE_PLUGIN_ROOT}/scripts/status_reporter.py" --focus urgency
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" status --focus urgency
 ```
 </output>
 </example>
@@ -313,7 +313,7 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/status_reporter.py" --focus urgency
 <input>查询实体出场记录</input>
 <output>
 ```bash
-python -m data_modules.index_manager entity-appearances --entity "lintian" --project-root "."
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" index entity-appearances --entity "lintian"
 ```
 </output>
 </example>
@@ -322,7 +322,7 @@ python -m data_modules.index_manager entity-appearances --entity "lintian" --pro
 <input>v5.1: 迁移旧 state.json 到 SQLite</input>
 <output>
 ```bash
-python -m data_modules.migrate_state_to_sqlite --project-root "." --backup
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" migrate --backup
 # 自动备份 state.json,迁移数据到 index.db,精简 state.json
 ```
 </output>
@@ -339,5 +339,5 @@ python -m data_modules.migrate_state_to_sqlite --project-root "." --backup
 ❌ 使用旧版 data_modules.state_manager schema → ✅ 统一使用 entities_v3 结构
 ❌ v5.1 仍从 state.json 读取 entities_v3 → ✅ 改用 SQL 查询 index.db
 ❌ v5.1 仍写入 state.json 大数据 → ✅ 改用 SQLite 增量写入
-❌ v5.1 state.json 膨胀 → ✅ 运行迁移脚本: `python -m data_modules.migrate_state_to_sqlite`
+❌ v5.1 state.json 膨胀 → ✅ 运行迁移脚本: `python "${SCRIPTS_DIR}/webnovel.py" migrate`
 </errors>

+ 43 - 11
.claude/skills/webnovel-resume/SKILL.md

@@ -8,9 +8,41 @@ allowed-tools: Read Bash AskUserQuestion
 
 ## Project Root Guard(必须先确认)
 
-- 必须在项目根目录执行(需存在 `.webnovel/state.json`)
-- 若当前目录不存在该文件,先询问用户项目路径并 `cd` 进入
-- 进入后设置变量:`$PROJECT_ROOT = (Resolve-Path ".").Path`
+- Claude Code 的“工作区根目录”不一定等于“书项目根目录”。常见结构:工作区为 `D:\wk\xiaoshuo`,书项目为 `D:\wk\xiaoshuo\凡人资本论`。
+- 必须先解析真实书项目根(必须包含 `.webnovel/state.json`),后续所有读写路径都以该目录为准。
+
+环境设置(bash 命令执行前):
+```bash
+export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
+
+if [ -d "${WORKSPACE_ROOT}/.claude/skills/webnovel-resume" ]; then
+  export SKILL_ROOT="${WORKSPACE_ROOT}/.claude/skills/webnovel-resume"
+elif [ -d "${WORKSPACE_ROOT}/../.claude/skills/webnovel-resume" ]; then
+  export SKILL_ROOT="${WORKSPACE_ROOT}/../.claude/skills/webnovel-resume"
+elif [ -d "${HOME}/.claude/skills/webnovel-resume" ]; then
+  export SKILL_ROOT="${HOME}/.claude/skills/webnovel-resume"
+elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume" ]; then
+  export SKILL_ROOT="${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume"
+else
+  echo "ERROR: 未找到 webnovel-resume skill 目录" >&2
+  exit 1
+fi
+
+if [ -d "${WORKSPACE_ROOT}/.claude/scripts" ]; then
+  export SCRIPTS_DIR="${WORKSPACE_ROOT}/.claude/scripts"
+elif [ -d "${WORKSPACE_ROOT}/../.claude/scripts" ]; then
+  export SCRIPTS_DIR="${WORKSPACE_ROOT}/../.claude/scripts"
+elif [ -d "${HOME}/.claude/scripts" ]; then
+  export SCRIPTS_DIR="${HOME}/.claude/scripts"
+elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
+  export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
+else
+  echo "ERROR: 未找到 scripts 目录" >&2
+  exit 1
+fi
+
+export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
+```
 
 ## Workflow Checklist
 
@@ -18,8 +50,8 @@ Copy and track progress:
 
 ```
 任务恢复进度:
-- [ ] Step 1: 加载恢复协议 (cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume/references/workflow-resume.md")
-- [ ] Step 2: 加载数据规范 (cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume/references/system-data-flow.md")
+- [ ] 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)
@@ -44,7 +76,7 @@ Copy and track progress:
 ## Step 1: 加载恢复协议(必须执行)
 
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume/references/workflow-resume.md"
+cat "${SKILL_ROOT}/references/workflow-resume.md"
 ```
 
 **核心原则**(读取后应用):
@@ -55,7 +87,7 @@ cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume/references/workflow-resume.md"
 ## Step 2: 加载数据规范
 
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume/references/system-data-flow.md"
+cat "${SKILL_ROOT}/references/system-data-flow.md"
 ```
 
 ## Step 3: 确认上下文充足
@@ -84,7 +116,7 @@ cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-resume/references/system-data-flow.md
 ## Step 4: 检测中断状态
 
 ```bash
-python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" detect
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow detect
 ```
 
 **输出情况**:
@@ -127,14 +159,14 @@ B) 回滚到Ch6,放弃Ch7所有进度
 
 **选项 A - 删除重来**(推荐):
 ```bash
-python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" cleanup --chapter {N} --confirm
-python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" clear
+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 "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" clear
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clear
 ```
 
 ## Step 7: 继续任务(可选)

+ 2 - 2
.claude/skills/webnovel-resume/references/system-data-flow.md

@@ -12,12 +12,12 @@ v5.4:版本号对齐,权威版本已更新。
 
 ## 权威版本位置
 
-`${CLAUDE_PLUGIN_ROOT}/skills/webnovel-query/references/system-data-flow.md` (v5.4)
+`../webnovel-query/references/system-data-flow.md` (v5.4)
 
 ## 加载方式
 
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-query/references/system-data-flow.md"
+cat "${SKILL_ROOT}/../webnovel-query/references/system-data-flow.md"
 ```
 
 ## 快速参考

+ 4 - 4
.claude/skills/webnovel-resume/references/workflow-resume.md

@@ -29,7 +29,7 @@ v5.4:版本号对齐,内容沿用 v5.2。
 ### Phase 1: 检测中断状态
 
 ```bash
-python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" detect
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow detect
 ```
 
 ### Phase 2: 询问用户
@@ -44,15 +44,15 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" detect
 
 **选项 A(推荐)**: 删除半成品重新开始
 ```bash
-python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" cleanup --chapter {N} --confirm
-python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" clear
+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**: 回滚到上一章
 ```bash
 git reset --hard ch{N-1:04d}
-python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" clear
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "$PROJECT_ROOT" workflow clear
 ```
 
 ## 为什么删除而不续写?

+ 48 - 16
.claude/skills/webnovel-review/SKILL.md

@@ -8,9 +8,41 @@ allowed-tools: Read Grep Write Edit Bash Task AskUserQuestion
 
 ## Project Root Guard(必须先确认)
 
-- 必须在项目根目录执行(需存在 `.webnovel/state.json`)
-- 若当前目录不存在该文件,先询问用户项目路径并 `cd` 进入
-- 进入后设置变量:`$PROJECT_ROOT = (Resolve-Path ".").Path`
+- Claude Code 的“工作区根目录”不一定等于“书项目根目录”。常见结构:工作区为 `D:\wk\xiaoshuo`,书项目为 `D:\wk\xiaoshuo\凡人资本论`。
+- 必须先解析真实书项目根(必须包含 `.webnovel/state.json`),后续所有读写路径都以该目录为准。
+
+环境设置(bash 命令执行前):
+```bash
+export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
+
+if [ -d "${WORKSPACE_ROOT}/.claude/skills/webnovel-review" ]; then
+  export SKILL_ROOT="${WORKSPACE_ROOT}/.claude/skills/webnovel-review"
+elif [ -d "${WORKSPACE_ROOT}/../.claude/skills/webnovel-review" ]; then
+  export SKILL_ROOT="${WORKSPACE_ROOT}/../.claude/skills/webnovel-review"
+elif [ -d "${HOME}/.claude/skills/webnovel-review" ]; then
+  export SKILL_ROOT="${HOME}/.claude/skills/webnovel-review"
+elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-review" ]; then
+  export SKILL_ROOT="${CLAUDE_PLUGIN_ROOT}/skills/webnovel-review"
+else
+  echo "ERROR: 未找到 webnovel-review skill 目录" >&2
+  exit 1
+fi
+
+if [ -d "${WORKSPACE_ROOT}/.claude/scripts" ]; then
+  export SCRIPTS_DIR="${WORKSPACE_ROOT}/.claude/scripts"
+elif [ -d "${WORKSPACE_ROOT}/../.claude/scripts" ]; then
+  export SCRIPTS_DIR="${WORKSPACE_ROOT}/../.claude/scripts"
+elif [ -d "${HOME}/.claude/scripts" ]; then
+  export SCRIPTS_DIR="${HOME}/.claude/scripts"
+elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
+  export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
+else
+  echo "ERROR: 未找到 scripts 目录" >&2
+  exit 1
+fi
+
+export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
+```
 
 ## 0.5 工作流断点(best-effort,不得阻断主流程)
 
@@ -18,7 +50,7 @@ allowed-tools: Read Grep Write Edit Bash Task AskUserQuestion
 
 推荐(bash):
 ```bash
-python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" start-task --command webnovel-review --chapter {end} || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-task --command webnovel-review --chapter {end} || true
 ```
 
 Step 映射(必须与 `workflow_manager.py get_pending_steps("webnovel-review")` 对齐):
@@ -33,8 +65,8 @@ Step 映射(必须与 `workflow_manager.py get_pending_steps("webnovel-review"
 
 Step 记录模板(bash,失败不阻断):
 ```bash
-python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" start-step --step-id "Step 1" --step-name "加载参考" || true
-python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" 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 1" --step-name "加载参考" || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 1" --artifacts '{"ok":true}' || true
 ```
 
 ## Review depth
@@ -60,19 +92,19 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" complete-step --step-
 
 **必读**:
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/references/shared/core-constraints.md"
+cat "${SKILL_ROOT}/../../references/shared/core-constraints.md"
 ```
 
 **建议(Full 或需要时)**:
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/references/shared/cool-points-guide.md"
-cat "${CLAUDE_PLUGIN_ROOT}/references/shared/strand-weave-pattern.md"
+cat "${SKILL_ROOT}/../../references/shared/cool-points-guide.md"
+cat "${SKILL_ROOT}/../../references/shared/strand-weave-pattern.md"
 ```
 
 **可选**:
 ```bash
-cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-review/references/common-mistakes.md"
-cat "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-review/references/pacing-control.md"
+cat "${SKILL_ROOT}/references/common-mistakes.md"
+cat "${SKILL_ROOT}/references/pacing-control.md"
 ```
 
 ## Step 2: 加载项目状态(若存在)
@@ -144,14 +176,14 @@ cat "$PROJECT_ROOT/.webnovel/state.json"
 ## Step 5: 保存审查指标到 index.db(必做)
 
 ```bash
-python -m data_modules.index_manager save-review-metrics --data '{...}' --project-root "${PROJECT_ROOT}"
+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 "${CLAUDE_PLUGIN_ROOT}/scripts/update_state.py" --project-root "$PROJECT_ROOT" --add-review "{start}-{end}" "审查报告/第{start}-{end}章审查报告.md"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" update-state --add-review "{start}-{end}" "审查报告/第{start}-{end}章审查报告.md"
 ```
 
 ## Step 7: 处理关键问题
@@ -170,7 +202,7 @@ python "${CLAUDE_PLUGIN_ROOT}/scripts/update_state.py" --project-root "$PROJECT_
 ## Step 8: 收尾(完成任务)
 
 ```bash
-python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" start-step --step-id "Step 8" --step-name "收尾" || true
-python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" complete-step --step-id "Step 8" --artifacts '{"ok":true}' || true
-python "${CLAUDE_PLUGIN_ROOT}/scripts/workflow_manager.py" complete-task --artifacts '{"ok":true}' || true
+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
 ```

+ 29 - 22
.claude/skills/webnovel-write/SKILL.md

@@ -102,24 +102,27 @@ allowed-tools: Read Write Edit Grep Bash Task
 ### Step 0:预检与上下文最小加载
 
 必须做:
-- 校验项目根:`.webnovel/state.json` 存在
+- 解析真实书项目根(book project_root):必须包含 `.webnovel/state.json`
 - 校验核心输入:`大纲/总纲.md`、`.claude/scripts/extract_chapter_context.py` 存在。
 - 规范化变量:
-  - `PROJECT_ROOT`:当前项目绝对路径(必须是用户小说项目,如 `/path/to/凡人资本论`)
+  - `WORKSPACE_ROOT`:Claude Code 打开的工作区根目录(可能是书项目的父目录,例如 `D:\wk\xiaoshuo`)
+  - `PROJECT_ROOT`:真实书项目根目录(必须包含 `.webnovel/state.json`,例如 `D:\wk\xiaoshuo\凡人资本论`)
   - `SKILL_ROOT`:skill 所在目录(即本 SKILL.md 所在的 `.claude/skills/webnovel-write`)
-  - `SCRIPTS_DIR`:脚本目录(按存在性自动探测,优先项目内 `.claude/scripts`,其次上级目录,再次 `${CLAUDE_PLUGIN_ROOT}/scripts`)
+  - `SCRIPTS_DIR`:脚本目录(按存在性自动探测:工作区内 `.claude/scripts` → 用户目录 `~/.claude/scripts` → `${CLAUDE_PLUGIN_ROOT}/scripts`)
   - `chapter_num`:当前章号(整数)
   - `chapter_padded`:四位章号(如 `0007`)
 
 环境设置(bash 命令执行前):
 ```bash
-# 示例:假设 PROJECT_ROOT=/d/wk/xiaoshuo/凡人资本论
-export PROJECT_ROOT="/d/wk/xiaoshuo/凡人资本论"
-
-if [ -d "${PROJECT_ROOT}/.claude/skills/webnovel-write" ]; then
-  export SKILL_ROOT="${PROJECT_ROOT}/.claude/skills/webnovel-write"
-elif [ -d "${PROJECT_ROOT}/../.claude/skills/webnovel-write" ]; then
-  export SKILL_ROOT="${PROJECT_ROOT}/../.claude/skills/webnovel-write"
+# WORKSPACE_ROOT:Claude Code 的工作区根(通常等于 $CLAUDE_PROJECT_DIR)
+export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
+
+if [ -d "${WORKSPACE_ROOT}/.claude/skills/webnovel-write" ]; then
+  export SKILL_ROOT="${WORKSPACE_ROOT}/.claude/skills/webnovel-write"
+elif [ -d "${WORKSPACE_ROOT}/../.claude/skills/webnovel-write" ]; then
+  export SKILL_ROOT="${WORKSPACE_ROOT}/../.claude/skills/webnovel-write"
+elif [ -d "${HOME}/.claude/skills/webnovel-write" ]; then
+  export SKILL_ROOT="${HOME}/.claude/skills/webnovel-write"
 elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/skills/webnovel-write" ]; then
   export SKILL_ROOT="${CLAUDE_PLUGIN_ROOT}/skills/webnovel-write"
 else
@@ -127,16 +130,21 @@ else
   exit 1
 fi
 
-if [ -d "${PROJECT_ROOT}/.claude/scripts" ]; then
-  export SCRIPTS_DIR="${PROJECT_ROOT}/.claude/scripts"
-elif [ -d "${PROJECT_ROOT}/../.claude/scripts" ]; then
-  export SCRIPTS_DIR="${PROJECT_ROOT}/../.claude/scripts"
+if [ -d "${WORKSPACE_ROOT}/.claude/scripts" ]; then
+  export SCRIPTS_DIR="${WORKSPACE_ROOT}/.claude/scripts"
+elif [ -d "${WORKSPACE_ROOT}/../.claude/scripts" ]; then
+  export SCRIPTS_DIR="${WORKSPACE_ROOT}/../.claude/scripts"
+elif [ -d "${HOME}/.claude/scripts" ]; then
+  export SCRIPTS_DIR="${HOME}/.claude/scripts"
 elif [ -n "${CLAUDE_PLUGIN_ROOT}" ] && [ -d "${CLAUDE_PLUGIN_ROOT}/scripts" ]; then
   export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
 else
   echo "ERROR: 未找到 scripts 目录" >&2
   exit 1
 fi
+
+# 解析真实书项目根(后续所有 Read/Write 路径都必须以 $PROJECT_ROOT 为前缀,避免写到工作区根目录)
+export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
 ```
 
 输出:
@@ -145,18 +153,17 @@ fi
 ### Step 0.5:工作流断点记录(best-effort,不阻断)
 
 ```bash
-# workflow_manager 必须传 --project-root 参数
 # 开始整条任务
-python "${SCRIPTS_DIR}/workflow_manager.py" --project-root "${PROJECT_ROOT}" start-task --command webnovel-write --chapter {chapter_num} || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-task --command webnovel-write --chapter {chapter_num} || true
 
 # 进入某一步(示例:Step 1)
-python "${SCRIPTS_DIR}/workflow_manager.py" --project-root "${PROJECT_ROOT}" start-step --step-id "Step 1" --step-name "Context Agent" || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow start-step --step-id "Step 1" --step-name "Context Agent" || true
 
 # Step 1 完成后记录(每个 Step 结束都要调用)
-python "${SCRIPTS_DIR}/workflow_manager.py" --project-root "${PROJECT_ROOT}" complete-step --step-id "Step 1" --artifacts '{"ok":true}' || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-step --step-id "Step 1" --artifacts '{"ok":true}' || true
 
 # 全部 Step 结束后,再结束整条任务
-python "${SCRIPTS_DIR}/workflow_manager.py" --project-root "${PROJECT_ROOT}" complete-task --artifacts '{"ok":true}' || true
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" workflow complete-task --artifacts '{"ok":true}' || true
 ```
 
 要求:
@@ -241,8 +248,8 @@ cat "${SKILL_ROOT}/references/step-3-review-gate.md"
 
 审查指标落库(必做):
 ```bash
-# 必须先 cd 到脚本目录,否则 Python 找不到 data_modules
-cd "${SCRIPTS_DIR}" && python -m data_modules.index_manager --project-root "${PROJECT_ROOT}" save-review-metrics --data '{...}'
+# 统一入口 webnovel.py:无需 cd / PYTHONPATH,且自动注入 --project-root
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" index save-review-metrics --data '@review_metrics.json'
 ```
 
 硬要求:
@@ -319,7 +326,7 @@ git commit -m "Ch{chapter_num}: {title}"
 test -f "${PROJECT_ROOT}/.webnovel/state.json"
 test -f "${PROJECT_ROOT}/正文/第${chapter_padded}章.md"
 test -f "${PROJECT_ROOT}/.webnovel/summaries/ch${chapter_padded}.md"
-cd "${SCRIPTS_DIR}" && python -m data_modules.index_manager --project-root "${PROJECT_ROOT}" get-recent-review-metrics --limit 1
+python "${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
 ```
 

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

@@ -86,7 +86,7 @@ parallel Task(agent, {chapter, chapter_file, project_root}) for agent in selecte
 ## 审查指标落库(必做)
 
 ```bash
-python -m data_modules.index_manager save-review-metrics --data '{...}' --project-root "${PROJECT_ROOT}"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" index save-review-metrics --data '@review_metrics.json'
 ```
 
 ## 进入 Step 4 前闸门

+ 1 - 1
.claude/skills/webnovel-write/references/step-5-debt-switch.md

@@ -10,7 +10,7 @@
 ## 执行命令
 
 ```bash
-python -m data_modules.index_manager accrue-interest --current-chapter {chapter_num} --project-root "${PROJECT_ROOT}"
+python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" index accrue-interest --current-chapter {chapter_num}
 ```
 
 ## 执行后要求