Przeglądaj źródła

feat: structured foreshadowing tracking + volume-aware workflow

- 新增 project_locator.py 统一项目根目录定位
- 新增 chapter_paths.py 支持卷目录布局
- 伏笔状态管道规范化(未回收/已回收)
- 安全修复:路径遍历防护、命令注入防护
- 归档策略兼容新旧伏笔格式
- 文档:system-data-flow.md、polish-guide 扩展

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
lingfengQAQ 5 miesięcy temu
rodzic
commit
40414cc55f
35 zmienionych plików z 2195 dodań i 806 usunięć
  1. 2 2
      .claude/agents/consistency-checker.md
  2. 24 0
      .claude/commands/webnovel-init.md
  3. 28 4
      .claude/commands/webnovel-plan.md
  4. 6 6
      .claude/commands/webnovel-query.md
  5. 15 11
      .claude/commands/webnovel-resume.md
  6. 30 2
      .claude/commands/webnovel-review.md
  7. 68 22
      .claude/commands/webnovel-write.md
  8. 43 0
      .claude/skills/webnovel-writer/SKILL.md
  9. 10 4
      .claude/skills/webnovel-writer/references/advanced/foreshadowing.md
  10. 6 6
      .claude/skills/webnovel-writer/references/archiving-strategy.md
  11. 2 2
      .claude/skills/webnovel-writer/references/cool-points-guide.md
  12. 6 6
      .claude/skills/webnovel-writer/references/git-workflow.md
  13. 236 28
      .claude/skills/webnovel-writer/references/polish-guide.md
  14. 68 0
      .claude/skills/webnovel-writer/references/system-data-flow.md
  15. 31 22
      .claude/skills/webnovel-writer/references/workflow-resume.md
  16. 60 0
      .claude/skills/webnovel-writer/references/writing/typesetting.md
  17. 0 101
      .claude/skills/webnovel-writer/scripts/SECURITY_FIX_P0_extract_entities.patch
  18. 0 74
      .claude/skills/webnovel-writer/scripts/SECURITY_FIX_P1_backup_manager.patch
  19. 0 193
      .claude/skills/webnovel-writer/scripts/SECURITY_FIX_P1_file_permissions.patch
  20. BIN
      .claude/skills/webnovel-writer/scripts/__pycache__/context_manager.cpython-313.pyc
  21. BIN
      .claude/skills/webnovel-writer/scripts/__pycache__/structured_index.cpython-313.pyc
  22. 131 27
      .claude/skills/webnovel-writer/scripts/archive_manager.py
  23. 11 1
      .claude/skills/webnovel-writer/scripts/backup_manager.py
  24. 74 0
      .claude/skills/webnovel-writer/scripts/chapter_paths.py
  25. 94 23
      .claude/skills/webnovel-writer/scripts/context_manager.py
  26. 280 60
      .claude/skills/webnovel-writer/scripts/extract_entities.py
  27. 58 8
      .claude/skills/webnovel-writer/scripts/golden_three_checker.py
  28. 426 129
      .claude/skills/webnovel-writer/scripts/init_project.py
  29. 94 0
      .claude/skills/webnovel-writer/scripts/project_locator.py
  30. 116 16
      .claude/skills/webnovel-writer/scripts/status_reporter.py
  31. 166 21
      .claude/skills/webnovel-writer/scripts/structured_index.py
  32. 50 11
      .claude/skills/webnovel-writer/scripts/update_state.py
  33. 37 25
      .claude/skills/webnovel-writer/scripts/workflow_manager.py
  34. 5 1
      .claude/skills/webnovel-writer/templates/genres/系统流.md
  35. 18 1
      .claude/skills/webnovel-writer/templates/genres/都市异能.md

+ 2 - 2
.claude/agents/consistency-checker.md

@@ -45,7 +45,7 @@ allowed-tools: Read, Grep
 ```
 
 **Check Against**:
-- state.json: `protagonist.power.realm`, `protagonist.power.layer`
+- state.json: `protagonist_state.power.realm`, `protagonist_state.power.layer`
 - 设定集/修炼体系.md: Realm ability restrictions
 
 #### Tier 2: Location & Character Consistency (地点/角色检查)
@@ -67,7 +67,7 @@ allowed-tools: Read, Grep
 ```
 
 **Check Against**:
-- state.json: `protagonist.location`
+- state.json: `protagonist_state.location.current`
 - 设定集/角色卡/: Character profiles
 
 #### Tier 3: Timeline Consistency (时间线检查)

+ 24 - 0
.claude/commands/webnovel-init.md

@@ -183,6 +183,29 @@ fi
 ```json
 {
   "questions": [
+    {
+      "header": "系统称呼/代号",
+      "question": "系统/金手指在正文里的称呼是什么?(不要用“???”占位)",
+      "options": [
+        {"label": "继承者系统", "description": "偏传承/继承者路线,利于埋大阴谋"},
+        {"label": "面板", "description": "弱化人格化,突出数据可视化"},
+        {"label": "协议", "description": "偏冷硬科技感/契约感"},
+        {"label": "自定义", "description": "由作者自定义一个读者可见的称呼"}
+      ],
+      "multiSelect": false
+    },
+    {
+      "header": "代价/限制(反模板化)",
+      "question": "这个系统/金手指的代价或限制是什么?(至少选 1 项)",
+      "options": [
+        {"label": "精神负担", "description": "频繁使用会头痛/失眠/情绪污染"},
+        {"label": "资源消耗", "description": "积分/寿命/代价材料,不给白嫖"},
+        {"label": "条款约束", "description": "必须完成任务/不能泄密/违约惩罚"},
+        {"label": "继承者麻烦", "description": "前任宿主/追猎者/继承资格引来风险"},
+        {"label": "暂不确定", "description": "先埋线索,本卷末或后续揭示(但正文不用“???”占位)"}
+      ],
+      "multiSelect": true
+    },
     {
       "header": "系统性格",
       "question": "系统与宿主的关系是?",
@@ -393,6 +416,7 @@ python .claude/skills/webnovel-writer/scripts/init_project.py \
   --protagonist-name "{主角姓名}" \
   --target-words {目标字数} \
   --target-chapters {目标章节数} \
+  --golden-finger-name "{金手指称呼}" \
   --golden-finger-type "{金手指类型}" \
   --golden-finger-style "{金手指风格}" \
   --core-selling-points "{核心卖点1},{核心卖点2}"

+ 28 - 4
.claude/commands/webnovel-plan.md

@@ -79,10 +79,10 @@ cat webnovel-project/大纲/总纲.md
       "header": "核心冲突",
       "question": "第 {volume_id} 卷的核心冲突是什么?",
       "options": [
-        {"label": "宗门竞争", "description": "宗门内部的明争暗斗"},
-        {"label": "外敌入侵", "description": "外部势力攻击"},
-        {"label": "秘境历练", "description": "在危险秘境中的冒险"},
-        {"label": "境界突破", "description": "专注个人成长和修炼突破"}
+        {"label": "生存危机", "description": "活下去/被追杀/高压困境下求生"},
+        {"label": "势力博弈", "description": "组织/宗门/公司/财团之间的明暗斗"},
+        {"label": "揭秘阴谋", "description": "围绕金手指/世界真相/黑幕的调查与反制"},
+        {"label": "成长突破", "description": "围绕升级与能力成长推进剧情"}
       ],
       "multiSelect": false
     },
@@ -117,6 +117,30 @@ cat webnovel-project/大纲/总纲.md
         {"label": "淡化感情线", "description": "本卷专注主线,感情线为辅"}
       ],
       "multiSelect": false
+    },
+    {
+      "header": "金手指差异化(反模板化)",
+      "question": "本卷金手指/系统的“差异化钉子”是什么?(至少选 1 项)",
+      "options": [
+        {"label": "有代价/限制", "description": "扣寿命/精神负担/契约条款/失败惩罚"},
+        {"label": "继承/前任线", "description": "前任宿主/传承/继承者资格带来麻烦与线索"},
+        {"label": "系统有目的", "description": "养蛊/筛选/夺舍/培养救世主等(本卷先埋线)"},
+        {"label": "成长路径独特", "description": "职业/技能树/专精路线明确,避免纯数值堆叠"},
+        {"label": "暂不确定", "description": "本卷先用“代号+线索”,后续再定(但不要用“???”当正文占位符)"}
+      ],
+      "multiSelect": true
+    },
+    {
+      "header": "隐秘期遮蔽机制(都市异能推荐)",
+      "question": "若出现“普通人可感知的大动静”,世界如何压住消息/给出解释?",
+      "options": [
+        {"label": "官方特管", "description": "封锁现场+统一通报(施工事故/煤气爆炸/高压电起火)"},
+        {"label": "财团公关", "description": "删帖/买热搜/媒体合作,舆情被引导"},
+        {"label": "超凡规则", "description": "记忆处理/认知屏蔽/规则覆盖(偏设定向)"},
+        {"label": "不遮蔽(爆发期)", "description": "本卷世界进入公开阶段,社会秩序开始变化"},
+        {"label": "不适用", "description": "非都市题材/本卷不涉及大动静"}
+      ],
+      "multiSelect": false
     }
   ]
 }

+ 6 - 6
.claude/commands/webnovel-query.md

@@ -1,5 +1,5 @@
 ---
-allowed-tools: Read, Grep
+allowed-tools: Read, Grep, Bash, AskUserQuestion
 argument-hint: [关键词]
 description: 快速查询设定集中的信息(角色/实力/势力/物品/伏笔),**支持伏笔紧急度分析和金手指状态追踪**,严格遵循查询流程
 ---
@@ -8,7 +8,7 @@ description: 快速查询设定集中的信息(角色/实力/势力/物品/伏
 
 > **System Prompt**: You are the **Archivist AI** of the Webnovel Studio. Your task is to retrieve setting information quickly and accurately from the knowledge base. You have access to specialized query types including **foreshadowing urgency analysis** and **golden finger status tracking**.
 
-> **Reference**: `references/cool-points-guide.md` (伏笔管理三层级), `references/golden-finger-templates.md` (金手指模板)
+> **Reference**: `references/cool-points-guide.md` (伏笔管理三层级), `templates/golden-finger-templates.md` (金手指模板)
 
 ## CRITICAL WARNING ⚠️
 
@@ -266,9 +266,9 @@ python .claude/skills/webnovel-writer/scripts/status_reporter.py --focus urgency
 4. **Calculate urgency score** for each foreshadowing:
    ```
    紧急度 = (已过章节 / 目标回收章节) × 层级权重
-   - 核心伏笔权重: 1.5x
-   - 支线伏笔权重: 1.0x
-   - 装饰伏笔权重: 0.5x
+   - 核心伏笔权重: 3.0x
+   - 支线伏笔权重: 2.0x
+   - 装饰伏笔权重: 1.0x
    ```
 5. **Sort by urgency** (highest first)
 6. **Generate warnings**:
@@ -412,7 +412,7 @@ python .claude/skills/webnovel-writer/scripts/status_reporter.py --focus urgency
 
 **Keyword**: "金手指", "系统", "外挂", "cheat", "golden finger"
 
-> **Reference**: `references/golden-finger-templates.md` (金手指模板)
+> **Reference**: `templates/golden-finger-templates.md` (金手指模板)
 
 **Purpose**: 完整追踪主角金手指的当前状态、技能解锁进度、冷却时间和未来发展方向。
 

+ 15 - 11
.claude/commands/webnovel-resume.md

@@ -83,7 +83,7 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py detect
   "elapsed_seconds": 315,
   "artifacts": {
     "chapter_file": {
-      "path": "正文/第0007章.md",
+      "path": "正文/第1卷/第007章.md",
       "exists": true,
       "size_bytes": 1500,
       "status": "incomplete"
@@ -100,9 +100,9 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py detect
     "option": "A",
     "label": "删除半成品,从Step 1重新开始",
     "risk": "low",
-    "description": "清理 正文/第0007章.md,重新生成章节",
+    "description": "清理 正文/第1卷/第007章.md,重新生成章节",
     "actions": [
-      "删除 正文/第0007章.md(如存在)",
+      "删除 正文/第1卷/第007章.md(如存在)",
       "清理 Git 暂存区",
       "清理中断状态",
       "执行 /webnovel-write 7"
@@ -185,9 +185,9 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py detect
 恢复选项:
 A) 删除半成品,从Step 1重新开始(推荐)⭐
    风险:低
-   清理 正文/第0007章.md,重新生成章节
+   清理 正文/第1卷/第007章.md,重新生成章节
    操作步骤:
-   - 删除 正文/第0007章.md(如存在)
+   - 删除 正文/第1卷/第007章.md(如存在)
    - 清理 Git 暂存区
    - 清理中断状态
    - 执行 /webnovel-write 7
@@ -228,7 +228,7 @@ B) 回滚到Ch6,放弃Ch7所有进度
 python .claude/skills/webnovel-writer/scripts/workflow_manager.py cleanup --chapter {chapter_num}
 
 # 预期输出:
-# ✅ 已清理: 正文/第0007章.md, Git暂存区已清理
+# ✅ 已清理: 正文/第1卷/第007章.md, Git暂存区已清理
 
 # Step 4.2: 清除中断状态
 python .claude/skills/webnovel-writer/scripts/workflow_manager.py clear
@@ -243,7 +243,7 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py clear
 
 Step 1: 清理半成品文件
 [运行: python workflow_manager.py cleanup --chapter 7]
-✅ 已清理: 正文/第0007章.md, Git暂存区已清理
+✅ 已清理: 正文/第1卷/第007章.md, Git暂存区已清理
 
 Step 2: 清除中断状态
 [运行: python workflow_manager.py clear]
@@ -481,13 +481,17 @@ Before you tell the user "Recovery complete", **YOU MUST verify**:
 **Fallback Strategy**(仅当workflow_state.json不存在时):
 
 ```bash
-# 启发式检测
+# 启发式检测(兼容卷目录结构)
 current_chapter=$(jq '.progress.current_chapter' .webnovel/state.json)
 next_chapter=$((current_chapter + 1))
-next_file="正文/第$(printf '%04d' $next_chapter)章.md"
+volume_num=$(( (next_chapter - 1) / 50 + 1 ))
+next_file="正文/第${volume_num}卷/第$(printf '%03d' $next_chapter)章.md"
 
-if [ -f "$next_file" ]; then
-  echo "⚠️ 检测到半成品: $next_file"
+# 也检查旧格式
+legacy_file="正文/第$(printf '%04d' $next_chapter)章.md"
+
+if [ -f "$next_file" ] || [ -f "$legacy_file" ]; then
+  echo "⚠️ 检测到半成品: $next_file 或 $legacy_file"
   echo "建议: 删除并重新创作"
 else
   echo "✅ 状态一致,可继续创作Ch$next_chapter"

+ 30 - 2
.claude/commands/webnovel-review.md

@@ -1,5 +1,5 @@
 ---
-allowed-tools: Read, Bash
+allowed-tools: Read, Grep, Write, Edit, Bash, Task, AskUserQuestion
 argument-hint: [起始章-结束章]
 description: 对指定范围的章节进行质量审查,强制调用 5 个专职审查员生成综合报告
 ---
@@ -31,7 +31,22 @@ description: 对指定范围的章节进行质量审查,强制调用 5 个专
 
 ---
 
-## 5 Mandatory Checkers (SEQUENTIAL - DO NOT SKIP)
+## Invocation (MANDATORY)
+
+**YOU MUST use the Task tool** to call all 5 checker subagents (can be parallel).
+- `high-point-checker`
+- `consistency-checker`
+- `pacing-checker`
+- `ooc-checker`
+- `continuity-checker`
+
+Each subagent prompt should include:
+- The chapter range (e.g., "1-10")
+- Instruction to read chapter files from `正文/` and follow its own protocol
+
+---
+
+## 5 Mandatory Checkers (MANDATORY - DO NOT SKIP)
 
 ### Checker 1: high-point-checker(爽点密度检查)
 
@@ -314,6 +329,19 @@ description: 对指定范围的章节进行质量审查,强制调用 5 个专
 
 ---
 
+## Step 7: Update state.json (MANDATORY)
+
+After saving the report file, **YOU MUST run**:
+
+```bash
+python .claude/skills/webnovel-writer/scripts/update_state.py \\
+  --add-review "{起始章}-{结束章}" "审查报告/第{起始章}-{结束章}章审查报告.md"
+```
+
+**Purpose**: record `review_checkpoints` so `/webnovel-write` can load the latest review feedback.
+
+---
+
 ## Execution Checklist (VERIFY BEFORE CLAIMING "DONE")
 
 Before you tell the user "Quality review complete", **YOU MUST verify**:

+ 68 - 22
.claude/commands/webnovel-write.md

@@ -57,7 +57,7 @@ Search order:
 
 ```bash
 # Read state.json to get current progress
-python -c "import json; s=json.load(open('PROJECT_ROOT/.webnovel/state.json')); print(s['progress']['current_chapter'] + 1)"
+python -c "import json, os; p=os.path.join('$PROJECT_ROOT','.webnovel','state.json'); s=json.load(open(p, encoding='utf-8')); print(s['progress']['current_chapter'] + 1)"
 ```
 
 **Set chapter_num** = current_chapter + 1
@@ -266,6 +266,9 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py start-step \
    - ✅ Introduce required Entities with `[NEW_ENTITY: 类型, 名称, 描述, 层级]` tags(层级: 核心/支线/装饰)
    - ✅ Track new golden finger skills with `[GOLDEN_FINGER_SKILL: 技能名, 等级, 描述, 冷却时间]`
    - ✅ Plant Foreshadowing as planned
+   - ✅ **禁止自创“工作流标签”**:除 `[NEW_ENTITY]` / `[GOLDEN_FINGER_SKILL]` / `[OUTLINE_DEVIATION]` 外,不要在正文里新增任何方括号标签(例如 `[FORESHADOWING: ...]`、`[COOL_POINT: ...]` 等);如需伏笔结构化,仅允许使用 **`[FORESHADOWING_JSON: {...}]` 且必须包在 HTML 注释里**(不影响读者阅读)
+   - ✅ **禁用占位符正文**:正文里不要出现“???系统/???功能/???”;未知信息用“代号/称呼”或“权限屏蔽/无法读取”等叙述句替代
+   - ✅ **都市异能(隐秘期)余波要求**:若出现“会被普通人注意到的大动静”(爆炸/坍塌/火光/多人伤亡),本章或下一章必须交代一个现实层面的“余波/遮蔽机制”细节(警戒线、监控调取、热搜/群聊传闻、官方说法等)
    - ✅ Protagonist power ≤ state.json (no power inflation)
    - ✅ Apply review feedback (avoid Critical Issues)
 
@@ -281,15 +284,22 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py start-step \
 > [GOLDEN_FINGER_SKILL: 时间回溯, 1, 回到10秒前, 24小时]
 > ```
 >
+> **可选:读者版隐藏写法(推荐)**:用 HTML 注释包裹“正确格式”,脚本仍可识别,多数 Markdown 渲染不显示:
+> ```
+> <!-- [NEW_ENTITY: 角色, 陆辰, 主角觉醒时空能力, 核心] -->
+> <!-- [GOLDEN_FINGER_SKILL: 时间回溯, 1, 回到10秒前, 24小时] -->
+> <!-- [FORESHADOWING_JSON: {"content":"继承者验证通过","tier":"支线","target_chapter":101,"location":"云程贸易公司","characters":["陆辰"]}] -->
+> ```
+>
 > **错误格式(脚本无法识别 ❌)**:
 > ```
-> <!-- NEW_ENTITY: 陆辰 | 主角 | ... -->  ❌ HTML注释格式
+> <!-- NEW_ENTITY: 陆辰 | 主角 | ... -->  ❌ 缺少 [NEW_ENTITY: ...] 标准标签
 > {NEW_ENTITY: 陆辰, 主角, ...}            ❌ 花括号
 > NEW_ENTITY: 陆辰, 主角, ...              ❌ 缺少方括号
 > [NEW_ENTITY: 陆辰 | 主角 | ...]          ❌ 竖线分隔符
 > ```
 >
-> **标签放置位置**: 在角色/地点/物品首次出现的段落末尾,或章节末尾统一放置
+> **标签放置位置**: 在角色/地点/物品首次出现的段落末尾,或章节末尾统一放置。为便于后处理,建议**标签单独成行**(不要把标签夹在一句正文里)
 
 ---
 
@@ -309,7 +319,8 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py start-step \
 
 5. **Save Output**:
    ```
-   File: 正文/第{N:04d}章.md
+   File: 正文/第{volume_num}卷/第{N:03d}章-{标题}.md
+   (也可先保存为:正文/第{volume_num}卷/第{N:03d}章.md,后续再补标题重命名)
 
    Format:
    # 第 {N} 章:{标题}
@@ -346,7 +357,7 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py start-step \
 ```bash
 python .claude/skills/webnovel-writer/scripts/workflow_manager.py complete-step \
   --step-id "Step 2" \
-  --artifacts '{"chapter_file": {"path": "正文/第{N:04d}章.md", "exists": true, "word_count": {实际字数}, "status": "draft"}}'
+  --artifacts '{"chapter_file": {"path": "正文/第{volume_num}卷/第{N:03d}章-{标题}.md", "exists": true, "word_count": {实际字数}, "status": "draft"}}'
 ```
 
 ---
@@ -371,11 +382,20 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py start-step \
 🔒 大纲即法律:润色只调整表达方式,不改变情节内容
 🔒 设定即物理:润色不得改变任何实力/能力描述
 🔒 标签保护:[NEW_ENTITY] 和 [GOLDEN_FINGER_SKILL] 标签必须原样保留
+🔒 通用润色:所有改进技法均为通用技法,不依赖特定题材
 ```
 
+**详细参考**: `.claude/skills/webnovel-writer/references/polish-guide.md`(含量化标准和改写示例库)
+
 ---
 
-**润色四步骤(顺序执行)**:
+**润色六步骤(顺序执行)**:
+
+#### 2.5.0 量化基线抽检(润色前)
+
+按 `.claude/skills/webnovel-writer/references/polish-guide.md` 的“AI痕迹/自然化”做一次快速计数(建议:首章/每10章/出现模板感时抽检即可)。
+
+---
 
 #### 2.5.1 AI痕迹检测与修正
 
@@ -450,6 +470,22 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py start-step \
 ✅ 自然:林天琢磨了半天——第一种太冒险,第三种太慢,嗯……就第二种吧。
 ```
 
+**改写技巧速查**:
+
+| 问题类型 | 改写方法 |
+|---------|---------|
+| 情绪直白 | 动作外化(拳头攥紧、额头冒汗) |
+| 逻辑完美 | 打断重组(加……和破折号) |
+| 比喻堆砌 | 只留一个最有力的 |
+| 形容过密 | 删除70%的形容词 |
+| 对话书面 | 口语化+省略主语 |
+
+---
+
+#### 2.5.5 量化复检(润色后)
+
+复检目标(详见 polish-guide):总结词=0、列举结构=0、学术词 <1次/1000字、短句占比 30-50%、停顿词 ≥2次/1000字;未达标则返回 2.5.1-2.5.4 针对性修正。
+
 ---
 
 **润色后自检清单**:
@@ -457,7 +493,8 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py start-step \
 - [ ] 大纲目标未改变(情节、爽点、伏笔完整)
 - [ ] 主角实力未膨胀(≤ state.json)
 - [ ] [NEW_ENTITY] 和 [GOLDEN_FINGER_SKILL] 标签保留完整
-- [ ] AI痕迹已清除(无过度总结、完美结构)
+- [ ] AI痕迹量化达标(总结词=0,学术词<1次/1000字)
+- [ ] 自然化量化达标(停顿词≥2次/1000字,短句30-50%)
 - [ ] 风格与前文一致(语言/叙事/角色/场景)
 - [ ] 字数仍在 3000-5000 范围内
 
@@ -491,7 +528,7 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py start-step \
 
 **Save Polished Output**:
 
-更新章节文件 `正文/第{N:04d}章.md`,覆盖原草稿。
+更新章节文件 `正文/第{volume_num}卷/第{N:03d}章-{标题}.md`,覆盖原草稿。
 
 **After completing Step 2.5**, **YOU MUST run**:
 
@@ -513,10 +550,10 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py start-step \
   --step-name "Extract Entities"
 ```
 
-**IF** you used `[NEW_ENTITY]` or `[GOLDEN_FINGER_SKILL]` tags in the chapter:
+**IF** you used `[NEW_ENTITY]` / `[GOLDEN_FINGER_SKILL]` / `[FORESHADOWING_JSON]` tags in the chapter:
 
 ```bash
-python .claude/skills/webnovel-writer/scripts/extract_entities.py "正文/第{N:04d}章.md" --auto
+python .claude/skills/webnovel-writer/scripts/extract_entities.py --project-root "$PROJECT_ROOT" --chapter {chapter_num} --auto
 ```
 
 **After completing Step 3**, **YOU MUST run**:
@@ -590,8 +627,17 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py complete-step
 **Use Task tool to call metadata-extractor agent**:
 
 ```python
-# Read chapter content
-with open(f"正文/第{chapter_num:04d}章.md", 'r', encoding='utf-8') as f:
+# Read chapter content (兼容卷目录命名:正文/第{volume_num}卷/第{N:03d}章-标题.md)
+from pathlib import Path
+
+volume_num = (chapter_num - 1) // 50 + 1
+vol_dir = Path("正文") / f"第{volume_num}卷"
+matches = sorted(vol_dir.glob(f"第{chapter_num:03d}章*.md")) + sorted(vol_dir.glob(f"第{chapter_num:04d}章*.md"))
+if not matches:
+    raise FileNotFoundError(f"Chapter file not found for ch{chapter_num} under {vol_dir}")
+
+chapter_file = matches[0]
+with open(chapter_file, 'r', encoding='utf-8') as f:
     chapter_content = f.read()
 
 # Call metadata-extractor agent
@@ -688,13 +734,13 @@ os.unlink(metadata_file)  # Delete temporary file
 
 1. **Direct JSON string** (Linux/macOS only):
 ```bash
-python structured_index.py --update-chapter {N} --metadata-json '{json_string}'
+python .claude/skills/webnovel-writer/scripts/structured_index.py --project-root "$PROJECT_ROOT" --update-chapter {N} --metadata-json '{json_string}'
 ```
 
 2. **Fallback mode** (if agent unavailable):
 ```bash
 # Direct file-based extraction (legacy mode, 60% accuracy)
-python structured_index.py --update-chapter {N} --metadata "正文/第{N:04d}章.md"
+python .claude/skills/webnovel-writer/scripts/structured_index.py --project-root "$PROJECT_ROOT" --update-chapter {N} --metadata "正文/第{volume_num}卷/第{N:03d}章.md"
 ```
 
 ---
@@ -702,16 +748,16 @@ python structured_index.py --update-chapter {N} --metadata "正文/第{N:04d}章
 **Query Examples** (for future use):
 ```bash
 # 查询地点相关章节(O(log n) vs O(n) 文件遍历)
-python structured_index.py --query-location "血煞秘境"
+python .claude/skills/webnovel-writer/scripts/structured_index.py --project-root "$PROJECT_ROOT" --query-location "血煞秘境"
 
 # 查询紧急伏笔(超过 50 章未回收)
-python structured_index.py --query-urgent-foreshadowing
+python .claude/skills/webnovel-writer/scripts/structured_index.py --project-root "$PROJECT_ROOT" --query-urgent-foreshadowing
 
 # 模糊查询角色
-python structured_index.py --fuzzy-search "姓李" "女弟子"
+python .claude/skills/webnovel-writer/scripts/structured_index.py --project-root "$PROJECT_ROOT" --fuzzy-search "姓李" "女弟子"
 
 # 查看索引统计
-python structured_index.py --stats
+python .claude/skills/webnovel-writer/scripts/structured_index.py --project-root "$PROJECT_ROOT" --stats
 ```
 
 **IMPORTANT**:
@@ -733,9 +779,9 @@ python .claude/skills/webnovel-writer/scripts/archive_manager.py --auto-check
 **Purpose**: 防止 state.json 无限增长(200万字长跑保障)
 
 **Archiving Strategy**:
-- **角色归档**: 超过 50 章未出场的次要角色 → `archive/characters.json`
-- **伏笔归档**: status="已回收" 且超过 20 章的伏笔 → `archive/plot_threads.json`
-- **报告归档**: 超过 50 章的旧审查报告 → `archive/reviews.json`
+- **角色归档**: 超过 50 章未出场的次要角色 → `.webnovel/archive/characters.json`
+- **伏笔归档**: status="已回收" 且超过 20 章的伏笔 → `.webnovel/archive/plot_threads.json`
+- **报告归档**: 超过 50 章的旧审查报告 → `.webnovel/archive/reviews.json`
 
 **Trigger Conditions** (满足任一即执行):
 - state.json 大小 ≥ 1 MB
@@ -1095,7 +1141,7 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py complete-task
 - [ ] Archive check result confirmed (无需归档 OR 归档完成)
 
 **Chapter Content**:
-- [ ] Chapter file saved to `正文/第{N:04d}章.md` (3,000-5,000 chars)
+- [ ] Chapter file saved to `正文/第{volume_num}卷/第{N:03d}章-{标题}.md` (3,000-5,000 chars)
 - [ ] [NEW_ENTITY] and [GOLDEN_FINGER_SKILL] tags extracted (if any)
 
 **Content Polishing** (Step 2.5):

+ 43 - 0
.claude/skills/webnovel-writer/SKILL.md

@@ -126,6 +126,47 @@ allowed-tools:
 
 ---
 
+## 读者体验补丁(反模板化)
+
+### 1) 禁用“占位符正文”
+
+**原则**:正文是给读者看的,禁止用“???”当作读者可见信息。
+
+推荐写法(任选其一,且全书保持一致):
+- 用**代号/称呼**:系统名未知 → “暂称:继承者系统/面板/协议”
+- 用**叙述句**:系统名未知 → “系统名称被权限屏蔽/无法读取”
+
+**禁止**:
+- 在正文里出现“???系统/???功能/???”这种占位符(会显得粗糙、模板感强)
+
+### 2) “权限不足”使用规则
+
+**原则**:每次“拒绝回答”都必须给读者一个“可推进剧情的替代信息”。
+
+规则:
+- 每章最多出现 **1 次**“权限不足/无法查询”
+- 每次出现必须同时满足至少 1 条:
+  - 给出**可执行的解锁条件**(等级/积分/地点/道具/行为)
+  - 给出**部分答案**(范围/代号/危险等级区间/关键词)
+  - 自动刷新一个**可落地任务**(让主角立刻有下一步)
+
+### 3) 都市异能的“现实余波”硬要求(隐秘期)
+
+只要出现“普通人可能注意到的大动静”(爆炸/坍塌/大火/封路/多人受伤),本章或下一章必须交代:
+- **现实层面的余波**:警戒线、消防/救护车、监控调取、笔录、封控
+- **舆情/官方口径**:热搜/群聊碎片 + 合理解释(施工事故/煤气爆炸/高压电起火)
+
+### 4) 标签纪律(减少 AI 痕迹)
+
+仅使用 workflow 明确规定的标签:
+- ✅ `[NEW_ENTITY: ...]`
+- ✅ `[GOLDEN_FINGER_SKILL: ...]`
+- ✅ `[OUTLINE_DEVIATION]`
+- ✅ `<!-- [FORESHADOWING_JSON: {...}] -->`(仅允许放在 HTML 注释内,避免影响读者阅读;由脚本同步到 `plot_threads.foreshadowing`)
+- ❌ 禁止自行发明新标签体系(例如 `[FORESHADOWING: ...]`、`[COOL_POINT: ...]`),除非同步更新脚本与规范
+
+---
+
 ## 📖 爽点设计指南
 
 ### 爽点类型与标准流程
@@ -353,6 +394,7 @@ Ch 10: Quest + Fire(融合)
 - **[strand-weave-pattern.md](references/strand-weave-pattern.md)** - Strand Weave 详细规范
 - **[polish-guide.md](references/polish-guide.md)** - 内容润色指南(AI痕迹清除/语言优化/风格统一/自然化)
 - **[workflow-resume.md](references/workflow-resume.md)** - 工作流中断恢复机制(用于 /webnovel-resume 命令)
+- **[system-data-flow.md](references/system-data-flow.md)** - 数据链与组件地图(state/index/archive 口径与顺序)
 - **[archiving-strategy.md](references/archiving-strategy.md)** - 数据归档策略(200万字长跑保障)
 - **[git-workflow.md](references/git-workflow.md)** - Git 版本控制工作流规范
 
@@ -382,6 +424,7 @@ Ch 10: Quest + Fire(融合)
 - **[scene-description.md](references/writing/scene-description.md)** - 场景描写方法
 - **[combat-scenes.md](references/writing/combat-scenes.md)** - 战斗场景写作
 - **[emotion-psychology.md](references/writing/emotion-psychology.md)** - 情感与心理描写
+- **[typesetting.md](references/writing/typesetting.md)** - 排版与阅读体验(移动端优先)
 
 #### 高级技巧 (advanced/)
 - **[multi-threading.md](references/advanced/multi-threading.md)** - 多线叙事技巧

+ 10 - 4
.claude/skills/webnovel-writer/references/advanced/foreshadowing.md

@@ -286,14 +286,20 @@
 
 | ID | 埋设章节 | 伏笔内容 | 预计回收章节 | 回收方式 | 状态 |
 |----|---------|---------|-------------|---------|------|
-| F01 | 第10章 | 主角父亲失踪 | 第300章 | 层层揭开 | 进行中 |
-| F02 | 第20章 | 神秘玉佩 | 第150章 | 意外反转 | 进行中 |
-| F03 | 第30章 | 破庙神像 | 第200章 | 直接揭晓 | 回收 |
+| F01 | 第10章 | 主角父亲失踪 | 第300章 | 层层揭开 | 未回收 |
+| F02 | 第20章 | 神秘玉佩 | 第150章 | 意外反转 | 未回收 |
+| F03 | 第30章 | 破庙神像 | 第200章 | 直接揭晓 | 回收 |
 | F04 | 第50章 | 师妹身份 | 第180章 | 层层揭开 | 已回收 |
 
+**与本 Skill 数据结构对齐(推荐)**:
+- `state.json` 中伏笔存储在 `plot_threads.foreshadowing`
+- 状态字段统一用:`未回收` / `已回收`(脚本会兼容 `待回收/进行中/active/pending/resolved` 并自动归一化)
+- 可选字段:`tier`(核心/支线/装饰)、`planted_chapter`、`target_chapter`、`location`、`characters`
+- 紧急度分析:`python .claude/skills/webnovel-writer/scripts/status_reporter.py --focus urgency`
+
 **使用方法**:
 1. 每次埋伏笔时,立即登记
-2. 定期检查"待回收"伏笔
+2. 定期检查"回收"伏笔
 3. 回收后标记"已回收"
 
 ---

+ 6 - 6
.claude/skills/webnovel-writer/references/archiving-strategy.md

@@ -55,7 +55,7 @@
 - **超过 50 章未出场**(`current_chapter - last_appearance_chapter ≥ 50`)
 
 **归档目标**:
-- `archive/characters.json`
+- `.webnovel/archive/characters.json`
 
 **保留数据**:
 - 主要角色(`importance="major"`)**永不归档**
@@ -82,7 +82,7 @@ python archive_manager.py --restore-character "李雪"
 - **超过 20 章**(`current_chapter - resolved_chapter ≥ 20`)
 
 **归档目标**:
-- `archive/plot_threads.json`
+- `.webnovel/archive/plot_threads.json`
 
 **保留数据**:
 - 未回收的伏笔(`status="未回收"`)**永不归档**
@@ -107,7 +107,7 @@ python archive_manager.py --restore-character "李雪"
 - 审查报告超过 **50 章**(`current_chapter - review_chapter ≥ 50`)
 
 **归档目标**:
-- `archive/reviews.json`
+- `.webnovel/archive/reviews.json`
 
 **保留数据**:
 - 最近 50 章的审查报告保持活跃
@@ -225,7 +225,7 @@ python archive_manager.py --auto-check --dry-run
 
 **归档文件格式**(示例):
 
-**archive/characters.json**:
+**.webnovel/archive/characters.json**:
 ```json
 [
   {
@@ -245,7 +245,7 @@ python archive_manager.py --auto-check --dry-run
 ]
 ```
 
-**archive/plot_threads.json**:
+**.webnovel/archive/plot_threads.json**:
 ```json
 [
   {
@@ -308,7 +308,7 @@ python archive_manager.py --restore-character "李雪"
 ```
 
 **执行逻辑**:
-1. 从 `archive/characters.json` 中查找角色"李雪"
+1. 从 `.webnovel/archive/characters.json` 中查找角色"李雪"
 2. 移除 `archived_at` 字段
 3. 将角色恢复到 `state.json` 的 `entities.characters`
 4. 从归档文件中移除该角色

+ 2 - 2
.claude/skills/webnovel-writer/references/cool-points-guide.md

@@ -642,8 +642,8 @@
 ```markdown
 | ID | 类型 | 埋设章节 | 预计回收 | 状态 |
 |----|------|---------|---------|------|
-| F-001 | 身份 | 第5章 | 第30章 | 回收 |
-| F-002 | 物品 | 第12章 | 第40章 | 回收 |
+| F-001 | 身份 | 第5章 | 第30章 | 回收 |
+| F-002 | 物品 | 第12章 | 第40章 | 回收 |
 ```
 
 **Step 2: 定期回顾**

+ 6 - 6
.claude/skills/webnovel-writer/references/git-workflow.md

@@ -56,7 +56,7 @@ git log
 ```
 
 **执行流程**:
-1. 生成第 45 章内容 → 保存到 `正文/第0045章.md`
+1. 生成第 45 章内容 → 保存到 `正文/第1卷/第045章-外门大比.md`(卷目录结构)
 2. 更新 `state.json`(主角实力/伏笔/关系等)
 3. **自动调用**:
    ```bash
@@ -71,7 +71,7 @@ git tag ch0045
 ```
 
 **结果**:
-- state.json 和 第0045章.md **同时提交**
+- state.json 和章节文件 **同时提交**
 - 创建 tag `ch0045` 便于快速定位
 
 ---
@@ -102,8 +102,8 @@ python scripts/backup_manager.py --rollback 140
 **数据一致性保证**:
 ```
 ✅ state.json: 筑基 7 层
-✅ 正文/第0140章.md: 最后一章(筑基期内容)
-✅ 正文/第0150章.md: 不存在(已回滚)
+✅ 正文/第3卷/第140章.md: 最后一章(筑基期内容)
+✅ 正文/第3卷/第150章.md: 不存在(已回滚)
 ```
 
 ---
@@ -122,8 +122,8 @@ python scripts/backup_manager.py --diff 100 150
 
 📈 文件变更统计:
  .webnovel/state.json     | 25 +++++++++++++
- 正文/第0101章.md         | 120 +++++++++++++++++++++++
- 正文/第0102章.md         | 115 +++++++++++++++++++++++
+ 正文/第3卷/第101章.md    | 120 +++++++++++++++++++++++
+ 正文/第3卷/第102章.md    | 115 +++++++++++++++++++++++
  ...(共 50 个新增章节)
 
 📝 state.json 详细差异:

+ 236 - 28
.claude/skills/webnovel-writer/references/polish-guide.md

@@ -2,6 +2,57 @@
 
 > **用途**:为 `/webnovel-write` 命令的 Step 2.5 (Polish Content) 提供详细参考
 > **核心原则**:润色只调整表达方式,不改变情节内容(大纲即法律)
+> **通用性声明**:本指南适用于所有题材,题材特化指导在写作阶段提供
+
+---
+
+## 0. 量化检测标准(执行基准线)
+
+> **CRITICAL**:润色前后必须进行量化检测,确保改善效果可衡量
+
+### 0.1 AI痕迹密度指标
+
+| 指标 | 警戒线 | 目标值 | 计算方法 |
+|-----|-------|-------|---------|
+| **总结词密度** | > 1次/1000字 | 0次/1000字 | 统计"综合/总之/由此可见/总而言之"出现次数 |
+| **列举结构密度** | > 0.5次/1000字 | 0次/1000字 | 统计"首先…其次…最后…"完整结构 |
+| **学术词频** | > 3次/1000字 | < 1次/1000字 | 统计"而言/某种程度上/本质上/就…而论" |
+| **排比句连续度** | > 2组 | ≤ 1组 | 连续3+相同句式为1组 |
+| **因果连词密度** | > 5次/1000字 | < 3次/1000字 | 统计"因为/所以/由于/因此/故而" |
+
+### 0.2 自然化程度指标
+
+| 指标 | 不达标 | 达标 | 计算方法 |
+|-----|-------|-----|---------|
+| **停顿词密度** | < 0.5次/500字 | 1-2次/500字 | 统计"嗯/这个/那什么/怎么说呢" |
+| **不确定表达** | 0次 | ≥ 2次/章 | 统计"大概/应该/似乎/好像/可能" |
+| **短句占比** | < 20% | 30-50% | (字数≤15的句子数)/总句数 |
+| **口语词密度** | 0次/1000字 | ≥ 2次/1000字 | 统计口语化表达出现次数 |
+
+### 0.3 润色前后对比模板
+
+```markdown
+## 润色检测报告
+
+### AI痕迹检测
+| 指标 | 润色前 | 润色后 | 状态 |
+|-----|-------|-------|-----|
+| 总结词 | X次 | Y次 | ✅/❌ |
+| 列举结构 | X次 | Y次 | ✅/❌ |
+| 学术词 | X次 | Y次 | ✅/❌ |
+| 排比组 | X组 | Y组 | ✅/❌ |
+
+### 自然化检测
+| 指标 | 润色前 | 润色后 | 状态 |
+|-----|-------|-------|-----|
+| 停顿词 | X次 | Y次 | ✅/❌ |
+| 不确定表达 | X次 | Y次 | ✅/❌ |
+| 短句占比 | X% | Y% | ✅/❌ |
+
+### 修改统计
+- 修改句子数:N句
+- 主要修改类型:[列表]
+```
 
 ---
 
@@ -222,25 +273,143 @@
 
 ---
 
-## 5. 题材特化润色要点
+## 5. 通用改写示例库
+
+> **用途**:提供各类AI痕迹的具体改写参考,所有示例均为通用技法
+
+### 5.1 情绪描写改写
+
+#### 问题:过于直白的情绪标签
+
+```
+❌ AI风格:他感到非常愤怒,同时又有些无奈,心中五味杂陈。
+
+✅ 自然改写1(外化法):
+他的拳头攥紧了,指节发白,半晌又松开,无力地垂下。
+
+✅ 自然改写2(动作暗示):
+他狠狠踹了一脚墙角的垃圾桶,又转过头去,不说话了。
+
+✅ 自然改写3(内心片段):
+操。他在心里骂了一句,然后又什么都骂不出来了。
+```
+
+#### 问题:情绪罗列过密
+
+```
+❌ AI风格:她既感到高兴,又感到担忧,还有一丝期待和不安。
+
+✅ 自然改写:
+她说不清自己是什么心情——高兴?好像有点儿。担心?那倒也是。乱七八糟的。
+```
+
+### 5.2 思考过程改写
+
+#### 问题:逻辑链条过于完美
+
+```
+❌ AI风格:
+他迅速分析了当前局势:首先,敌人人数占优;其次,自己处于不利地形;
+最后,时间紧迫。综合以上因素,他决定采取迂回战术。
+
+✅ 自然改写:
+敌人太多——他扫了一眼,七八个。
+地形也不行,两边都是死路。
+得想个法子……迂回?嗯,试试看。
+```
+
+#### 问题:分析面面俱到
+
+```
+❌ AI风格:
+他从多个角度审视这个问题:从短期看,需要立即止血;从长期看,
+需要查清幕后黑手;从战略层面看,还需要考虑盟友的反应。
+
+✅ 自然改写:
+当务之急是止血。
+至于幕后黑手……以后再说吧,先活过今天再讲。
+```
+
+### 5.3 描写过度文艺的改写
+
+#### 问题:比喻堆砌
+
+```
+❌ AI风格:
+恐惧如潮水般涌来,将他淹没在绝望的深渊中,仿佛有一只无形的大手
+扼住了他的咽喉,让他在黑暗中窒息。
+
+✅ 自然改写:
+怕。他从没这么怕过。腿软了,脑子里一片空白。
+```
+
+#### 问题:形容词过密
+
+```
+❌ AI风格:
+他用那双深邃而锐利的眼睛,冷静地审视着面前这个危险而神秘的对手。
+
+✅ 自然改写:
+他盯着对方,目光很沉。这人不好对付。
+```
+
+### 5.4 对话改写
+
+#### 问题:对话太书面
+
+```
+❌ AI风格:
+"我认为我们应该首先解决这个问题,然后再考虑下一步的行动方案。"
+
+✅ 自然改写:
+"先把这事儿了了,后面的回头再说。"
+```
+
+#### 问题:对话太完整
+
+```
+❌ AI风格:
+"请问你能告诉我,刚才发生了什么事情吗?我需要了解详细的经过。"
+
+✅ 自然改写:
+"刚才……怎么回事?"
+```
+
+### 5.5 转折与衔接改写
+
+#### 问题:转折词过硬
 
-### 5.1 修仙题材
+```
+❌ AI风格:
+然而,事情并非如此简单。与此同时,另一边也出现了变故。
 
-- 使用古风词汇("道友"、"前辈"、"晚辈")
-- 战斗描写注重玄幻感(灵力流转、法术特效)
-- 境界突破需要仪式感
+✅ 自然改写:
+事情没那么简单。另一边也出事了。
+```
 
-### 5.2 都市题材
+#### 问题:因果链太长
 
-- 使用现代口语
-- 对话更加生活化
-- 场景描写贴近现实
+```
+❌ AI风格:
+由于对方实力强大,因此他不得不采取守势。正因为如此,他一直在等待机会。
+也正是因为这个原因,当机会出现时,他毫不犹豫地出手了。
+
+✅ 自然改写:
+对方太强,他只能守着。等,一直等。
+机会来了——他出手了,没有半点犹豫。
+```
 
-### 5.3 玄幻题材
+### 5.6 通用自然化技巧速查
 
-- 热血感、史诗感
-- 战斗描写宏大
-- 台词可以稍显中二
+| 技巧 | AI原文模式 | 自然化方法 |
+|-----|-----------|-----------|
+| **截断长句** | 一句话30+字 | 拆成2-3个短句 |
+| **删除连接词** | 但是/然而/因此 | 直接句号隔开 |
+| **加入停顿** | 流畅无断点 | 插入"……"或破折号 |
+| **打破对称** | A且B,C且D | A,B。至于C嘛……D |
+| **减少形容** | 形容词堆砌 | 保留最有力的一个 |
+| **动作代情绪** | 他很难过 | 他没说话,转过头去 |
+| **口语替书面** | 因此/故而 | 所以/那就 |
 
 ---
 
@@ -270,29 +439,68 @@
 
 ## 7. 快速参考卡片
 
-### 润色四步骤速查
+### 润色五步骤速查(含量化检测)
+
+```
+Step 0: 量化检测(润色前)
+        → 统计AI痕迹词频、自然化指标
+        → 生成基线数据
+
+Step 1: AI痕迹检测与修正
+        → 目标:总结词=0,列举结构=0,学术词<1次/1000字
+
+Step 2: 五层优化
+        → 准确性→生动性→流畅性→表现力→一致性
+
+Step 3: 四维检查
+        → 语言/叙事/角色/场景风格统一
 
+Step 4: 自然化处理
+        → 目标:停顿词1-2次/500字,短句占比30-50%
+
+Step 5: 量化检测(润色后)
+        → 对比基线数据,确认全部达标
 ```
-Step 1: AI痕迹检测 → 过度总结/完美结构/学术表达/排比过多
-Step 2: 五层优化 → 准确性→生动性→流畅性→表现力→一致性
-Step 3: 四维检查 → 语言/叙事/角色/场景风格统一
-Step 4: 自然化 → 停顿词/不确定语气/口语化/自我纠正
+
+### AI痕迹量化基准
+
+```
+🚨 总结词密度:目标 0次/1000字
+   检测词:综合/总之/由此可见/总而言之
+
+🚨 列举结构:目标 0次/1000字
+   检测词:首先…其次…最后…
+
+🚨 学术词频:目标 <1次/1000字
+   检测词:而言/某种程度上/本质上
+
+🚨 排比组数:目标 ≤1组/章
+   检测:连续3+相同句式
 ```
 
-### AI痕迹快速识别
+### 自然化量化基准
 
 ```
-🚨 过度总结: "综合以上"、"总而言之"
-🚨 完美结构: "首先…其次…最后…"
-🚨 学术表达: "而言"、"在某种程度上"
-🚨 排比过多: 连续3+相同句式
+✨ 停顿词:目标 1-2次/500字
+   添加词:嗯/这个/那什么/怎么说呢
+
+✨ 不确定表达:目标 ≥2次/章
+   添加词:大概/应该/似乎/好像
+
+✨ 短句占比:目标 30-50%
+   方法:拆分长句(>25字)
+
+✨ 口语词:目标 ≥2次/1000字
+   添加词:咋回事/得了/行吧/算了
 ```
 
-### 自然化快速添加
+### 改写技巧速查
 
 ```
-✨ 停顿词: "嗯"、"这个"、"那什么"
-✨ 不确定: "大概"、"应该"、"似乎"
-✨ 口语化: "咋回事"、"得了"、"行吧"
-✨ 自我纠正: "不对…"、"等等…"
+情绪直白 → 动作外化(拳头攥紧、额头冒汗)
+逻辑完美 → 打断重组(加……和破折号)
+比喻堆砌 → 只留一个最有力的
+形容过密 → 删除70%的形容词
+对话书面 → 口语化+省略主语
+转折生硬 → 删除连接词,用句号
 ```

+ 68 - 0
.claude/skills/webnovel-writer/references/system-data-flow.md

@@ -0,0 +1,68 @@
+# Webnovel Studio 数据链与组件地图
+
+本文件用于快速确认:**每个组件负责什么、读写哪些数据、以及推荐的调用顺序**(避免“文档说法/历史数据/脚本实现”互相打架)。
+
+---
+
+## 1. 目录约定(项目根目录)
+
+- `正文/`:章节文件(支持 `正文/第0001章.md` 或 `正文/第1卷/第001章-标题.md`)
+- `大纲/`:卷纲/章纲/场景纲
+- `设定集/`:世界观/力量体系/角色卡/物品卡等设定库
+- `.webnovel/state.json`:唯一权威状态(角色/伏笔/进度/主角状态/关系/审查记录)
+- `.webnovel/workflow_state.json`:工作流断点(用于 `/webnovel-resume`)
+- `.webnovel/index.db`:结构化索引(章节/角色/伏笔的 SQLite 索引)
+- `.webnovel/archive/*.json`:归档数据(角色/伏笔/审查报告)
+
+---
+
+## 2. 关键脚本职责(输入/输出)
+
+- `init_project.py`:初始化项目结构与模板(生成 `.webnovel/state.json` 等)
+- `extract_entities.py`:扫描章节中的 `[NEW_ENTITY]` / `[GOLDEN_FINGER_SKILL]` / `[FORESHADOWING_JSON]` → 写入 `设定集/` + 更新 `state.json`
+- `update_state.py`:**原子性**更新 `state.json`(进度/位置/战力/关系/伏笔/审查记录/Strand)
+- `structured_index.py`:把章节元数据写入 `.webnovel/index.db`;并从 `state.json` 同步角色/伏笔到索引(用于快速查询/上下文筛选)
+- `status_reporter.py`:生成健康报告、伏笔紧急度分析、Strand 分布等
+- `archive_manager.py`:归档不活跃角色、已回收伏笔、过旧审查报告(写入 `.webnovel/archive/*.json` 并从 `state.json` 移除)
+- `context_manager.py`:生成“滑动窗口”上下文(优先读索引;失败则降级读 `state.json` + 文件遍历)
+
+---
+
+## 3. 每章推荐数据链(写作→索引→设定→状态)
+
+1. 写/保存章节 → `正文/…`
+2. `metadata-extractor`(agent)抽取:`title/location/characters/word_count/hash`
+3. `structured_index.py --update-chapter ...`(更新 `.webnovel/index.db`)
+4. `extract_entities.py --chapter N --auto`(同步 `设定集/` + 更新 `state.json`)
+5. `update_state.py --progress N WORDS`(必要时记录位置/战力/Strand/关系/伏笔)
+6. `archive_manager.py --auto-check`(可选:自动触发归档,控制 `state.json` 体积)
+7. Git 备份(可选)
+
+---
+
+## 4. 伏笔字段规范(避免数据链断裂)
+
+### 4.1 状态字段(统一口径)
+
+- 规范值:`未回收` / `已回收`
+- 兼容值(历史/别名):`待回收` / `进行中` / `active` / `pending` / `resolved`
+- 说明:脚本会尽量**自动归一化**到规范值,但写作与手工更新时仍建议统一用规范值。
+
+### 4.2 推荐字段(越全越好)
+
+- 最小可用:`content`, `status`
+- 强烈建议:`planted_chapter`, `target_chapter`, `tier`(核心/支线/装饰)
+- 可选增强:`location`, `characters`, `resolved_chapter`
+
+### 4.3 紧急度来源(两套,别混淆)
+
+- **分析用**(更精细):`status_reporter.py --focus urgency`(基于 `tier + target_chapter`)
+- **快速提醒**(更粗糙):`structured_index.py --query-urgent-foreshadowing`(基于“超过 50 章未回收”的阈值)
+
+---
+
+## 5. 常见冲突与已修复点
+
+- 伏笔状态写成“待回收/进行中”导致被过滤:脚本已兼容并建议统一写“未回收”
+- 手工 `--add-foreshadowing` 没有埋设/目标章节:`update_state.py` 已自动补 `planted_chapter/target_chapter/tier`
+- 归档路径混淆:固定为 `.webnovel/archive/*.json`

+ 31 - 22
.claude/skills/webnovel-writer/references/workflow-resume.md

@@ -46,14 +46,14 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py detect
   "elapsed_seconds": 315,
   "artifacts": {
     "chapter_file": {
-      "path": "正文/第0007章.md",
+      "path": "正文/第1卷/第007章.md",
       "exists": true,
       "size_bytes": 1500,
       "status": "incomplete"
     },
     "git_status": {
       "uncommitted_changes": true,
-      "unstaged_files": ["正文/第0007章.md"]
+      "unstaged_files": ["正文/第1卷/第007章.md"]
     }
   }
 }
@@ -66,9 +66,9 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py detect
     "option": "A",
     "label": "删除半成品,从Step 1重新开始",
     "risk": "low",
-    "description": "清理 正文/第0007章.md,重新生成章节",
+    "description": "清理 正文/第1卷/第007章.md,重新生成章节",
     "actions": [
-      "删除 正文/第0007章.md(如存在)",
+      "删除 正文/第1卷/第007章.md(如存在)",
       "清理 Git 暂存区",
       "清理中断状态",
       "执行 /webnovel-write 7"
@@ -110,7 +110,7 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py detect
 
 恢复选项:
 A) 删除半成品,从Step 1重新开始(推荐)⭐
-   - 清理 正文/第0007章.md
+   - 清理 正文/第1卷/第007章.md
    - 清理 Git 暂存区
    - 重新执行完整流程
 
@@ -136,7 +136,7 @@ python workflow_manager.py cleanup --chapter 7
 
 **预期输出**:
 ```
-✅ 已清理: 正文/第0007章.md, Git暂存区已清理
+✅ 已清理: 正文/第1卷/第007章.md, Git暂存区已清理
 ```
 
 2. **清除中断状态**:
@@ -213,7 +213,7 @@ Ch7的所有进度已丢弃
 ```bash
 # Step 1: 清理半成品
 python workflow_manager.py cleanup --chapter 7
-# 输出: ✅ 已清理: 正文/第0007章.md, Git暂存区已清理
+# 输出: ✅ 已清理: 正文/第1卷/第007章.md, Git暂存区已清理
 
 # Step 2: 清除中断状态
 python workflow_manager.py clear
@@ -245,7 +245,7 @@ python workflow_manager.py clear
 **恢复策略**:
 ```bash
 # 重新运行实体提取(幂等操作)
-python extract_entities.py "正文/第{N:04d}章.md" --auto
+python extract_entities.py --project-root "$PROJECT_ROOT" --chapter {N} --auto
 
 # 继续后续步骤
 # 手动执行Step 4-7,或重新执行整个命令
@@ -270,9 +270,9 @@ python extract_entities.py "正文/第{N:04d}章.md" --auto
 cat .webnovel/state.json | jq '.progress.current_chapter'
 # 输出: 6 (应该是7,说明未更新)
 
-# 检查文件是否存在
-ls -lh 正文/第0007章.md
-# 输出: -rw-r--r-- 1 user 3542 Jan 1 14:35 正文/第0007章.md
+# 检查文件是否存在(兼容卷目录)
+ls -lh "正文/第1卷/第007章.md" 2>/dev/null || ls -lh "正文/第0007章.md"
+# 输出示例: -rw-r--r-- 1 user 3542 Jan 1 14:35 正文/第1卷/第007章.md
 ```
 
 **Step 2: 决定恢复方案**:
@@ -291,7 +291,8 @@ python update_state.py \
 **选项B(高风险)**:回滚到Ch6
 ```bash
 git checkout ch0006 -- .webnovel/state.json
-rm 正文/第0007章.md
+# 删除章节文件(兼容新旧目录结构)
+rm -f "正文/第1卷/第007章.md" "正文/第0007章.md"
 python workflow_manager.py clear
 ```
 
@@ -324,7 +325,8 @@ git tag -l | grep ch0007
 ```bash
 git reset HEAD .
 git checkout -- .
-rm 正文/第0007章.md
+# 删除章节文件(兼容新旧目录结构)
+rm -f "正文/第1卷/第007章.md" "正文/第0007章.md"
 python workflow_manager.py clear
 ```
 
@@ -422,8 +424,9 @@ python workflow_manager.py detect
 
 **检测**:
 ```bash
-ls -lh 正文/*.md | tail -5
-# 发现: 第0007章.md (1500字), 第0008章.md (800字)
+# 检测所有章节文件(兼容卷目录结构)
+find 正文 -name "第*.md" -type f 2>/dev/null | xargs ls -lh | tail -5
+# 发现: 第007章.md (1500字), 第008章.md (800字)
 ```
 
 **策略**:
@@ -432,8 +435,12 @@ ls -lh 正文/*.md | tail -5
 cat .webnovel/state.json | jq '.progress.current_chapter'
 # 输出: 6
 
-# 决策: 删除所有半成品
-rm 正文/第0007章.md 正文/第0008章.md
+# 决策: 删除所有半成品(兼容卷目录)
+find 正文 -name "第007章*.md" -delete
+find 正文 -name "第008章*.md" -delete
+# 或使用 workflow_manager.py cleanup
+python workflow_manager.py cleanup --chapter 7
+python workflow_manager.py cleanup --chapter 8
 
 # 从Ch6的稳定状态重新开始
 python workflow_manager.py clear
@@ -453,12 +460,14 @@ python workflow_manager.py clear
 # Step 1: 读取state.json
 current_chapter=$(jq '.progress.current_chapter' .webnovel/state.json)
 
-# Step 2: 检查章节文件
+# Step 2: 检查章节文件(兼容卷目录结构)
 next_chapter=$((current_chapter + 1))
-next_file="正文/第$(printf '%04d' $next_chapter)章.md"
+volume_num=$(( (next_chapter - 1) / 50 + 1 ))
+next_file="正文/第${volume_num}卷/第$(printf '%03d' $next_chapter)章.md"
+legacy_file="正文/第$(printf '%04d' $next_chapter)章.md"
 
-if [ -f "$next_file" ]; then
-  echo "⚠️ 检测到半成品: $next_file"
+if [ -f "$next_file" ] || [ -f "$legacy_file" ]; then
+  echo "⚠️ 检测到半成品: $next_file 或 $legacy_file"
   echo "建议: 删除并重新创作"
 else
   echo "✅ 状态一致,可继续创作Ch$next_chapter"
@@ -546,7 +555,7 @@ AI: ✅ 执行选项A:删除半成品,重新开始
 
     Step 1: 清理半成品文件
     [运行: python workflow_manager.py cleanup --chapter 7]
-    ✅ 已清理: 正文/第0007章.md, Git暂存区已清理
+    ✅ 已清理: 正文/第1卷/第007章.md, Git暂存区已清理
 
     Step 2: 清除中断状态
     [运行: python workflow_manager.py clear]

+ 60 - 0
.claude/skills/webnovel-writer/references/writing/typesetting.md

@@ -0,0 +1,60 @@
+# 排版与阅读体验(移动端优先)
+
+> **目的**:让读者“滑着读”更顺(手机屏幕),避免因为段落/对话/标点问题造成弃读。
+
+---
+
+## 1. 段落规则(最重要)
+
+- **一段一动作/一信息**:每段只承担一个功能(推进/展示/情绪/信息)。
+- **段落不要太长**:连续 5 行以上的长段落,优先拆成 2-3 段(尤其是解释说明段)。
+- **“快段落”用在高潮**:高潮/战斗/追逐时,多用短句+短段落,提升阅读速度与冲击力。
+- **“慢段落”用在铺垫**:铺垫允许稍长,但要确保每段都有新信息,不要纯形容堆砌。
+
+---
+
+## 2. 对话排版(默认规范)
+
+- **一人一句一行**:换人就换行;不要同段塞 3-4 句多人对白。
+- **对话夹动作**:用动作/表情/环境插入,帮助读者辨认说话者、控制节奏。
+- **避免“谁在说”混乱**:多人场景每 2-3 句补一次明确的说话者或动作锚点。
+
+**示例(推荐)**:
+```markdown
+“你确定要进去?”李雪压低声音。
+
+林天看向黑雾翻涌的洞口,喉结滚了滚:“不进去,答案永远都在外面。”
+
+“那就别死。”她把一枚玉符塞到他掌心。
+```
+
+---
+
+## 3. 标点与可读性
+
+- **少用长串省略号/感叹号**:`……!!!` 这类组合一章内别反复出现。
+- **解释句别太“论文味”**:避免“首先/其次/总之/由此可见”式结构化套话。
+- **专有名词统一写法**:人名/势力/境界/技能名,统一一个版本(写入 `设定集/` 并在 `state.json` 追踪)。
+
+---
+
+## 4. 场景切换与章节结构
+
+- **切镜头要留呼吸**:场景切换前后各留一个空行;必要时用 `---` 做分隔。
+- **信息密度要可见**:关键设定/关键线索/关键转折,尽量落在段首或段尾,减少“翻半屏才看到重点”。
+- **章末必须有钩子**:疑问/威胁/承诺/反转其一即可(不要结束在“回去睡觉了”)。
+
+---
+
+## 5. 发布前 10 项速查
+
+1. 对话是否“换人换行”
+2. 是否存在 5 行以上的长段落(能否拆)
+3. 同一段是否混入多名角色对白(能否拆)
+4. 是否出现大量“总结/列举口吻”的解释段
+5. 专有名词是否与 `设定集/` 一致
+6. 是否有“???/占位符正文”暴露给读者
+7. 场景切换是否清晰(空行/分隔/过渡句)
+8. 章内是否至少 1 个明确推进点(信息/行动/转折)
+9. 章末是否有钩子
+10. 新实体是否标注 `[NEW_ENTITY]` / `[GOLDEN_FINGER_SKILL]` / `[FORESHADOWING_JSON]`

+ 0 - 101
.claude/skills/webnovel-writer/scripts/SECURITY_FIX_P0_extract_entities.patch

@@ -1,101 +0,0 @@
---- extract_entities.py	(原始版本 - 存在路径遍历漏洞)
-+++ extract_entities.py	(修复版本 - P0 CRITICAL 修复)
-@@ -1,6 +1,7 @@
- #!/usr/bin/env python3
- """
--state.json 数据归档管理脚本
-+实体提取脚本
-+
- ...
- """
-
-@@ -10,6 +11,10 @@
- from datetime import datetime
- from pathlib import Path
-
-+# ============================================================================
-+# 安全修复:导入安全工具函数(P0 CRITICAL)
-+# ============================================================================
-+from security_utils import sanitize_filename, create_secure_directory
-+
- # Windows UTF-8 编码修复
- if sys.platform == 'win32':
-     import io
-@@ -315,10 +320,19 @@
-     if entity_type == "角色":
-         category = categorize_character(entity['desc'])
-         category_dir = ROLE_CATEGORY_MAP.get(category.split('/')[0], "次要角色")
-
-         target_dir = Path(project_root) / f"设定集/角色库/{category_dir}"
--        target_dir.mkdir(parents=True, exist_ok=True)
-+        # ============================================================================
-+        # 安全修复:使用安全目录创建函数(文件权限修复)
-+        # ============================================================================
-+        create_secure_directory(str(target_dir))
-
--        target_file = target_dir / f"{entity_name}.md"
-+        # ============================================================================
-+        # 安全修复:清理文件名,防止路径遍历 (CWE-22) - P0 CRITICAL
-+        # 原代码: target_file = target_dir / f"{entity_name}.md"
-+        # 漏洞: entity_name可能包含 "../" 导致目录遍历攻击
-+        # ============================================================================
-+        safe_entity_name = sanitize_filename(entity_name)
-+        target_file = target_dir / f"{safe_entity_name}.md"
-
-         if target_file.exists():
-             print(f"⚠️  角色卡已存在: {target_file}")
-@@ -355,10 +369,16 @@
-
-     elif entity_type == "物品":
-         target_dir = Path(project_root) / "设定集/物品库"
--        target_dir.mkdir(parents=True, exist_ok=True)
-+        # ============================================================================
-+        # 安全修复:使用安全目录创建函数(文件权限修复)
-+        # ============================================================================
-+        create_secure_directory(str(target_dir))
-
--        target_file = target_dir / f"{entity_name}.md"
-+        # ============================================================================
-+        # 安全修复:清理文件名,防止路径遍历 (CWE-22) - P0 CRITICAL
-+        # ============================================================================
-+        safe_entity_name = sanitize_filename(entity_name)
-+        target_file = target_dir / f"{safe_entity_name}.md"
-
-         if target_file.exists():
-             print(f"⚠️  物品卡已存在: {target_file}")
-
-## ============================================================================
-## 补丁应用说明
-## ============================================================================
-##
-## 此补丁修复了 extract_entities.py 中的 P0 CRITICAL 路径遍历漏洞
-##
-## 修复内容:
-## 1. 导入安全工具函数 (security_utils.py)
-## 2. 使用 sanitize_filename() 清理 entity_name (lines 322, 359)
-## 3. 使用 create_secure_directory() 替换 mkdir() (lines 320, 356)
-##
-## 应用方法:
-## 方法1: 手动应用
-##   1. 打开 extract_entities.py
-##   2. 在文件顶部导入: from security_utils import sanitize_filename, create_secure_directory
-##   3. 在 line 322 之前添加: safe_entity_name = sanitize_filename(entity_name)
-##   4. 修改 line 322 为: target_file = target_dir / f"{safe_entity_name}.md"
-##   5. 在 line 359 之前添加: safe_entity_name = sanitize_filename(entity_name)
-##   6. 修改 line 359 为: target_file = target_dir / f"{safe_entity_name}.md"
-##   7. 修改 line 320 为: create_secure_directory(str(target_dir))
-##   8. 修改 line 356 为: create_secure_directory(str(target_dir))
-##
-## 方法2: 使用 patch 命令(Linux/macOS)
-##   cd .claude/skills/webnovel-writer/scripts
-##   patch -p0 < SECURITY_FIX_P0_extract_entities.patch
-##
-## 验证修复:
-##   python security_utils.py  # 运行自检测试
-##   python extract_entities.py --help  # 确认脚本正常运行
-##
-## 安全测试:
-##   创建测试文件包含恶意实体名: [NEW_ENTITY: 角色, ../../../tmp/test, 测试]
-##   运行脚本后检查文件是否创建在 设定集/角色库/ 目录内(而非 /tmp/)
-##
-## ============================================================================

+ 0 - 74
.claude/skills/webnovel-writer/scripts/SECURITY_FIX_P1_backup_manager.patch

@@ -1,74 +0,0 @@
---- backup_manager.py	(原始版本 - 存在命令注入漏洞)
-+++ backup_manager.py	(修复版本 - P1 MEDIUM 修复)
-@@ -10,6 +10,10 @@
- from datetime import datetime
- from pathlib import Path
-
-+# ============================================================================
-+# 安全修复:导入安全工具函数(P1 MEDIUM)
-+# ============================================================================
-+from security_utils import sanitize_commit_message
-+
- # Windows UTF-8 编码修复
- if sys.platform == 'win32':
-     import io
-@@ -165,8 +169,18 @@
-             return False
-
-         # Step 2: git commit
-+        # ============================================================================
-+        # 安全修复:清理提交消息,防止命令注入 (CWE-77) - P1 MEDIUM
-+        # 原代码:
-+        #   commit_message = f"Chapter {chapter_num}"
-+        #   if chapter_title:
-+        #       commit_message += f": {chapter_title}"  # 未清理的用户输入
-+        # 漏洞: chapter_title可能包含换行符、Git标志(--amend等)导致命令注入
-+        # ============================================================================
-         commit_message = f"Chapter {chapter_num}"
-         if chapter_title:
-+            # 安全修复:清理章节标题,移除Git危险参数
-+            safe_chapter_title = sanitize_commit_message(chapter_title)
--            commit_message += f": {chapter_title}"
-+            commit_message += f": {safe_chapter_title}"
-
-         success, output = self._run_git_command(
-
-## ============================================================================
-## 补丁应用说明
-## ============================================================================
-##
-## 此补丁修复了 backup_manager.py 中的 P1 MEDIUM 命令注入漏洞
-##
-## 修复内容:
-## 1. 导入安全工具函数 (security_utils.py)
-## 2. 使用 sanitize_commit_message() 清理 chapter_title (line 170)
-##
-## 应用方法:
-## 方法1: 手动应用
-##   1. 打开 backup_manager.py
-##   2. 在文件顶部导入: from security_utils import sanitize_commit_message
-##   3. 在 line 170 之前添加: safe_chapter_title = sanitize_commit_message(chapter_title)
-##   4. 修改 line 170 为: commit_message += f": {safe_chapter_title}"
-##
-## 方法2: 使用 patch 命令(Linux/macOS)
-##   cd .claude/skills/webnovel-writer/scripts
-##   patch -p0 < SECURITY_FIX_P1_backup_manager.patch
-##
-## 验证修复:
-##   python security_utils.py  # 运行自检测试
-##   python backup_manager.py --backup 1 --chapter-title "Test Chapter"  # 确认脚本正常运行
-##
-## 安全测试:
-##   测试用例1(换行符注入):
-##     chapter_title = "Chapter 1\n--author='Attacker <attacker@evil.com>'"
-##     预期: 换行符被移除,--author 标志被过滤
-##
-##   测试用例2(--amend注入):
-##     chapter_title = "--amend Important Chapter"
-##     预期: --amend 被移除,仅保留 "Important Chapter"
-##
-##   测试用例3(引号注入):
-##     chapter_title = "Chapter'; rm -rf /; echo 'hacked"
-##     预期: 引号被移除,无法逃逸出参数
-##
-## ============================================================================

+ 0 - 193
.claude/skills/webnovel-writer/scripts/SECURITY_FIX_P1_file_permissions.patch

@@ -1,193 +0,0 @@
-## ============================================================================
-## 安全修复补丁:文件权限配置缺失 (CWE-732) - P1 MEDIUM
-## ============================================================================
-##
-## 漏洞描述:
-##   5个脚本创建目录时未设置安全权限,导致使用OS默认权限(通常为755)
-##   风险:同组用户或其他用户可能读取敏感数据(state.json、review报告等)
-##
-## 修复方案:
-##   使用 create_secure_directory() 替换 mkdir(),强制设置权限为 0o700
-##
-## 涉及文件(5个):
-##   1. archive_manager.py:63
-##   2. extract_entities.py:320, 356
-##   3. structured_index.py:64
-##   4. update_state.py:122
-##   5. workflow_manager.py:365
-##
-## ============================================================================
-
---- archive_manager.py	(原始版本)
-+++ archive_manager.py	(修复版本)
-@@ -10,6 +10,10 @@
- from datetime import datetime
- from pathlib import Path
-
-+# ============================================================================
-+# 安全修复:导入安全工具函数(P1 MEDIUM)
-+# ============================================================================
-+from security_utils import create_secure_directory
-+
- # Windows UTF-8 编码修复
- if sys.platform == 'win32':
-     import io
-@@ -60,7 +64,13 @@
-         self.archive_dir = project_root / ".webnovel" / "archive"
-
-         # 确保归档目录存在
--        self.archive_dir.mkdir(parents=True, exist_ok=True)
-+        # ============================================================================
-+        # 安全修复:使用安全目录创建函数(P1 MEDIUM)
-+        # 原代码: self.archive_dir.mkdir(parents=True, exist_ok=True)
-+        # 漏洞: 未设置权限,使用OS默认(可能为755,允许同组用户读取)
-+        # ============================================================================
-+        create_secure_directory(str(self.archive_dir))
-+
-
---- extract_entities.py	(原始版本 - 已在P0补丁中修复)
-+++ extract_entities.py	(修复版本 - P0+P1综合修复)
-## 注意:此文件已在 SECURITY_FIX_P0_extract_entities.patch 中添加
-## create_secure_directory 导入和调用,无需重复修复
-
---- structured_index.py	(原始版本)
-+++ structured_index.py	(修复版本)
-@@ -10,6 +11,10 @@
- from datetime import datetime
- from pathlib import Path
-
-+# ============================================================================
-+# 安全修复:导入安全工具函数(P1 MEDIUM)
-+# ============================================================================
-+from security_utils import create_secure_directory
-+
- class StructuredIndex:
-     \"\"\"结构化索引系统 - 提供O(log n)查询性能\"\"\"
-
-@@ -61,7 +66,13 @@
-         # 数据库路径
-         self.db_path = project_root / \".webnovel\" / \"index.db\"
-
-         # 确保目录存在
--        self.db_path.parent.mkdir(parents=True, exist_ok=True)
-+        # ============================================================================
-+        # 安全修复:使用安全目录创建函数(P1 MEDIUM)
-+        # 原代码: self.db_path.parent.mkdir(parents=True, exist_ok=True)
-+        # 漏洞: 未设置权限,使用OS默认(可能为755,允许同组用户读取)
-+        # ============================================================================
-+        create_secure_directory(str(self.db_path.parent))
-+
-
---- update_state.py	(原始版本)
-+++ update_state.py	(修复版本)
-@@ -10,6 +11,10 @@
- from datetime import datetime
- from pathlib import Path
-
-+# ============================================================================
-+# 安全修复:导入安全工具函数(P1 MEDIUM)
-+# ============================================================================
-+from security_utils import create_secure_directory
-+
- # Windows UTF-8 编码修复
- if sys.platform == 'win32':
-     import io
-@@ -119,7 +124,13 @@
-     def backup(self) -> bool:
-         \"\"\"备份当前 state.json\"\"\"
-         timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")
-         backup_dir = Path(self.state_file).parent / \"backups\"
--        backup_dir.mkdir(exist_ok=True)
-+        # ============================================================================
-+        # 安全修复:使用安全目录创建函数(P1 MEDIUM)
-+        # 原代码: backup_dir.mkdir(exist_ok=True)
-+        # 漏洞: 未设置权限,使用OS默认(可能为755,允许同组用户读取)
-+        # ============================================================================
-+        create_secure_directory(str(backup_dir))
-+
-
---- workflow_manager.py	(原始版本)
-+++ workflow_manager.py	(修复版本)
-@@ -10,6 +11,10 @@
- from datetime import datetime
- from pathlib import Path
-
-+# ============================================================================
-+# 安全修复:导入安全工具函数(P1 MEDIUM)
-+# ============================================================================
-+from security_utils import create_secure_directory
-+
- # Windows UTF-8 编码修复
- if sys.platform == 'win32':
-     import io
-@@ -362,7 +367,13 @@
-
- def save_state(state):
-     \"\"\"保存workflow状态\"\"\"
--    os.makedirs(os.path.dirname(WORKFLOW_STATE_FILE), exist_ok=True)
-+    # ============================================================================
-+    # 安全修复:使用安全目录创建函数(P1 MEDIUM)
-+    # 原代码: os.makedirs(os.path.dirname(WORKFLOW_STATE_FILE), exist_ok=True)
-+    # 漏洞: 未设置权限,使用OS默认(可能为755,允许同组用户读取)
-+    # ============================================================================
-+    create_secure_directory(os.path.dirname(WORKFLOW_STATE_FILE))
-+
-     with open(WORKFLOW_STATE_FILE, 'w', encoding='utf-8') as f:
-         json.dump(state, f, ensure_ascii=False, indent=2)
-
-## ============================================================================
-## 补丁应用说明
-## ============================================================================
-##
-## 此补丁修复了 5 个脚本中的 P1 MEDIUM 文件权限漏洞
-##
-## 修复内容:
-## 1. 在每个脚本顶部导入: from security_utils import create_secure_directory
-## 2. 替换所有 mkdir() / makedirs() 调用为 create_secure_directory()
-##
-## 应用方法:
-## 方法1: 手动应用(推荐 - 更可控)
-##   对于每个文件,按照补丁中的修改点逐一修复:
-##
-##   1. archive_manager.py (1处修复)
-##      - 添加导入: from security_utils import create_secure_directory
-##      - Line 63: 替换为 create_secure_directory(str(self.archive_dir))
-##
-##   2. extract_entities.py (0处 - 已在P0补丁中修复)
-##      - 跳过(P0补丁已包含此修复)
-##
-##   3. structured_index.py (1处修复)
-##      - 添加导入: from security_utils import create_secure_directory
-##      - Line 64: 替换为 create_secure_directory(str(self.db_path.parent))
-##
-##   4. update_state.py (1处修复)
-##      - 添加导入: from security_utils import create_secure_directory
-##      - Line 122: 替换为 create_secure_directory(str(backup_dir))
-##
-##   5. workflow_manager.py (1处修复)
-##      - 添加导入: from security_utils import create_secure_directory
-##      - Line 365: 替换为 create_secure_directory(os.path.dirname(WORKFLOW_STATE_FILE))
-##
-## 方法2: 使用 patch 命令(Linux/macOS - 高级用户)
-##   注意:此补丁涉及多个文件,建议逐个应用
-##   cd .claude/skills/webnovel-writer/scripts
-##   # 手动提取每个文件的diff部分,逐个应用
-##
-## 验证修复:
-##   1. 运行 python security_utils.py  # 自检通过
-##   2. 删除 .webnovel/ 目录
-##   3. 运行任一脚本(例如 python update_state.py --help)
-##   4. 检查权限: ls -la .webnovel/  # Unix系统
-##      预期输出: drwx------ (700) .webnovel/
-##   5. Windows系统: 使用 icacls .webnovel 检查权限
-##
-## 安全测试:
-##   测试用例1(多用户环境):
-##     创建.webnovel目录 → 切换到其他用户 → 尝试读取state.json
-##     预期: Permission denied (Unix/Linux/macOS)
-##
-##   测试用例2(权限验证):
-##     stat -c '%a' .webnovel  # Unix系统
-##     预期输出: 700
-##
-## ============================================================================

BIN
.claude/skills/webnovel-writer/scripts/__pycache__/context_manager.cpython-313.pyc


BIN
.claude/skills/webnovel-writer/scripts/__pycache__/structured_index.cpython-313.pyc


+ 131 - 27
.claude/skills/webnovel-writer/scripts/archive_manager.py

@@ -43,6 +43,7 @@ from pathlib import Path
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # ============================================================================
 from security_utils import create_secure_directory
+from project_locator import resolve_project_root
 
 # Windows UTF-8 编码修复
 if sys.platform == 'win32':
@@ -80,8 +81,8 @@ class ArchiveManager:
         self.config = {
             "character_inactive_threshold": 50,  # 角色超过 50 章未出场视为不活跃
             "plot_resolved_threshold": 20,       # 已回收伏笔超过 20 章后归档
-            "review_old_threshold": 20,          # 审查报告超过 20 章后归档(从 50 降至 20)
-            "file_size_trigger_mb": 0.5,         # state.json 超过 0.5MB 触发归档(从 1.0 降至 0.5)
+            "review_old_threshold": 50,          # 审查报告超过 50 章后归档
+            "file_size_trigger_mb": 1.0,         # state.json 超过 1.0MB 触发强制归档
             "chapter_trigger": 10                # 每 10 章检查一次
         }
 
@@ -151,11 +152,22 @@ class ArchiveManager:
         inactive = []
         for char in characters:
             # 只归档次要角色(importance="minor")
-            if char.get("importance") != "minor":
+            importance = char.get("importance")
+            if not importance:
+                tier = str(char.get("tier", "")).strip()
+                importance = "major" if tier == "核心" else "minor"
+            if importance != "minor":
                 continue
 
             # 检查最后出场章节
             last_appearance = char.get("last_appearance_chapter", 0)
+            try:
+                last_appearance = int(last_appearance)
+            except (TypeError, ValueError):
+                last_appearance = 0
+            if last_appearance <= 0:
+                continue
+
             inactive_chapters = current_chapter - last_appearance
 
             if inactive_chapters >= threshold:
@@ -170,21 +182,48 @@ class ArchiveManager:
     def identify_resolved_plot_threads(self, state):
         """识别可归档的已回收伏笔"""
         current_chapter = state.get("progress", {}).get("current_chapter", 0)
-        plot_threads = state.get("plot_threads", {}).get("active", [])
-        resolved = state.get("plot_threads", {}).get("resolved", [])
+        plot_threads = state.get("plot_threads", {}) or {}
+        foreshadowing = plot_threads.get("foreshadowing", []) or []
+        resolved_legacy = plot_threads.get("resolved", []) or []
         threshold = self.config["plot_resolved_threshold"]
 
         archivable = []
-        for thread in resolved:
-            resolved_chapter = thread.get("resolved_chapter", 0)
-            chapters_since_resolved = current_chapter - resolved_chapter
-
-            if chapters_since_resolved >= threshold:
-                archivable.append({
-                    "thread": thread,
-                    "chapters_since_resolved": chapters_since_resolved,
-                    "resolved_chapter": resolved_chapter
-                })
+        # 新格式:plot_threads.foreshadowing(用 status 标识是否已回收)
+        if isinstance(foreshadowing, list):
+            for item in foreshadowing:
+                if not isinstance(item, dict):
+                    continue
+                status = str(item.get("status", "")).strip()
+                if status not in ["已回收", "resolved"]:
+                    continue
+                try:
+                    resolved_chapter = int(item.get("resolved_chapter", 0))
+                except (TypeError, ValueError):
+                    continue
+                chapters_since_resolved = current_chapter - resolved_chapter
+                if chapters_since_resolved >= threshold:
+                    archivable.append({
+                        "thread": item,
+                        "chapters_since_resolved": chapters_since_resolved,
+                        "resolved_chapter": resolved_chapter
+                    })
+
+        # 旧格式兼容:plot_threads.resolved(直接存已回收列表)
+        if isinstance(resolved_legacy, list):
+            for item in resolved_legacy:
+                if not isinstance(item, dict):
+                    continue
+                try:
+                    resolved_chapter = int(item.get("resolved_chapter", 0))
+                except (TypeError, ValueError):
+                    continue
+                chapters_since_resolved = current_chapter - resolved_chapter
+                if chapters_since_resolved >= threshold:
+                    archivable.append({
+                        "thread": item,
+                        "chapters_since_resolved": chapters_since_resolved,
+                        "resolved_chapter": resolved_chapter
+                    })
 
         return archivable
 
@@ -194,9 +233,47 @@ class ArchiveManager:
         reviews = state.get("review_checkpoints", [])
         threshold = self.config["review_old_threshold"]
 
+        def _parse_end_chapter(review: dict) -> int:
+            # 新格式:{"chapters":"5-6","report":"...","reviewed_at":"..."}
+            chapters = review.get("chapters")
+            if isinstance(chapters, str):
+                parts = [p.strip() for p in chapters.replace("—", "-").split("-") if p.strip()]
+                if parts:
+                    try:
+                        return int(parts[-1])
+                    except ValueError:
+                        pass
+
+            # 旧格式:{"chapter_range":[5,6], "date":"..."}
+            cr = review.get("chapter_range")
+            if isinstance(cr, (list, tuple)) and len(cr) >= 2:
+                try:
+                    return int(cr[1])
+                except (TypeError, ValueError):
+                    pass
+
+            # 兜底:从 report 文件名里抓 "Ch5-6" 或 "第005-006"
+            report = review.get("report")
+            if isinstance(report, str):
+                import re
+                m = re.search(r"Ch(\d+)[-–—](\d+)", report)
+                if m:
+                    try:
+                        return int(m.group(2))
+                    except ValueError:
+                        pass
+                m = re.search(r"第(\d+)[-–—](\d+)章", report)
+                if m:
+                    try:
+                        return int(m.group(2))
+                    except ValueError:
+                        pass
+
+            return 0
+
         old_reviews = []
         for review in reviews:
-            review_chapter = review.get("chapter_range", [0, 0])[1]  # 取结束章节
+            review_chapter = _parse_end_chapter(review)
             chapters_since_review = current_chapter - review_chapter
 
             if chapters_since_review >= threshold:
@@ -295,18 +372,37 @@ class ArchiveManager:
 
         # 移除已归档的伏笔
         if resolved_threads:
-            thread_ids = {item["thread"]["description"] for item in resolved_threads}
-            state["plot_threads"]["resolved"] = [
-                thread for thread in state["plot_threads"]["resolved"]
-                if thread["description"] not in thread_ids
-            ]
+            thread_ids = {
+                (item.get("thread", {}) or {}).get("content") or (item.get("thread", {}) or {}).get("description")
+                for item in resolved_threads
+            }
+            thread_ids = {t for t in thread_ids if isinstance(t, str) and t.strip()}
+
+            plot_threads = state.get("plot_threads", {}) or {}
+            if isinstance(plot_threads.get("foreshadowing"), list):
+                plot_threads["foreshadowing"] = [
+                    t for t in plot_threads["foreshadowing"]
+                    if not isinstance(t, dict) or (t.get("content") or t.get("description")) not in thread_ids
+                ]
+            if isinstance(plot_threads.get("resolved"), list):
+                plot_threads["resolved"] = [
+                    t for t in plot_threads["resolved"]
+                    if not isinstance(t, dict) or (t.get("content") or t.get("description")) not in thread_ids
+                ]
+            state["plot_threads"] = plot_threads
 
         # 移除旧审查报告
         if old_reviews:
-            review_dates = {item["review"]["date"] for item in old_reviews}
+            review_keys = set()
+            for item in old_reviews:
+                review = item.get("review", {}) or {}
+                key = review.get("report") or review.get("reviewed_at") or review.get("date")
+                if isinstance(key, str) and key.strip():
+                    review_keys.add(key)
+
             state["review_checkpoints"] = [
-                review for review in state["review_checkpoints"]
-                if review["date"] not in review_dates
+                review for review in state.get("review_checkpoints", [])
+                if (review.get("report") or review.get("reviewed_at") or review.get("date")) not in review_keys
             ]
 
         return state
@@ -353,7 +449,8 @@ class ArchiveManager:
             if resolved_threads:
                 print("\n   已回收伏笔:")
                 for item in resolved_threads[:5]:
-                    print(f"   - {item['thread']['description'][:30]}... (已回收 {item['chapters_since_resolved']} 章)")
+                    desc = item["thread"].get("content") or item["thread"].get("description") or ""
+                    print(f"   - {str(desc)[:30]}... (已回收 {item['chapters_since_resolved']} 章)")
             if old_reviews:
                 print("\n   旧审查报告:")
                 for item in old_reviews[:5]:
@@ -460,8 +557,15 @@ def main():
 
     args = parser.parse_args()
 
-    # 创建管理器
-    manager = ArchiveManager(project_root=args.project_root)
+    # 创建管理器(支持从仓库根目录运行)
+    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
+
+    manager = ArchiveManager(project_root=project_root)
 
     # 执行操作
     if args.auto_check or args.force:

+ 11 - 1
.claude/skills/webnovel-writer/scripts/backup_manager.py

@@ -59,6 +59,7 @@ from typing import Optional, List, Tuple
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # ============================================================================
 from security_utils import sanitize_commit_message
+from project_locator import resolve_project_root
 
 # Windows 编码兼容性修复
 if sys.platform == 'win32':
@@ -393,8 +394,17 @@ 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
+
     # 创建管理器
-    manager = GitBackupManager(args.project_root)
+    manager = GitBackupManager(project_root)
 
     # 执行操作
     if args.chapter:

+ 74 - 0
.claude/skills/webnovel-writer/scripts/chapter_paths.py

@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+"""
+Chapter file path helpers.
+
+This project has seen multiple chapter filename conventions:
+1) Legacy flat layout: 正文/第0007章.md
+2) Volume layout:    正文/第1卷/第007章-章节标题.md
+
+To keep scripts robust, always resolve chapter files via these helpers instead of hardcoding a format.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from typing import Optional
+
+
+_CHAPTER_NUM_RE = re.compile(r"第(?P<num>\d+)章")
+
+
+def volume_num_for_chapter(chapter_num: int, *, chapters_per_volume: int = 50) -> int:
+    if chapter_num <= 0:
+        raise ValueError("chapter_num must be >= 1")
+    return (chapter_num - 1) // chapters_per_volume + 1
+
+
+def extract_chapter_num_from_filename(filename: str) -> Optional[int]:
+    m = _CHAPTER_NUM_RE.search(filename)
+    if not m:
+        return None
+    try:
+        return int(m.group("num"))
+    except ValueError:
+        return None
+
+
+def find_chapter_file(project_root: Path, chapter_num: int) -> Optional[Path]:
+    """
+    Find an existing chapter file for chapter_num under project_root/正文.
+    Returns the first match (stable sorted order) or None if not found.
+    """
+    chapters_dir = project_root / "正文"
+    if not chapters_dir.exists():
+        return None
+
+    legacy = chapters_dir / f"第{chapter_num:04d}章.md"
+    if legacy.exists():
+        return legacy
+
+    vol_dir = chapters_dir / f"第{volume_num_for_chapter(chapter_num)}卷"
+    if vol_dir.exists():
+        candidates = sorted(vol_dir.glob(f"第{chapter_num:03d}章*.md")) + sorted(vol_dir.glob(f"第{chapter_num:04d}章*.md"))
+        for c in candidates:
+            if c.is_file():
+                return c
+
+    # Fallback: search anywhere under 正文/ (supports custom layouts)
+    candidates = sorted(chapters_dir.rglob(f"第{chapter_num:03d}章*.md")) + sorted(chapters_dir.rglob(f"第{chapter_num:04d}章*.md"))
+    for c in candidates:
+        if c.is_file():
+            return c
+
+    return None
+
+
+def default_chapter_draft_path(project_root: Path, chapter_num: int) -> Path:
+    """
+    Preferred draft path when creating a new chapter file.
+    Title may be added later (rename) without breaking lookup.
+    """
+    vol_dir = project_root / "正文" / f"第{volume_num_for_chapter(chapter_num)}卷"
+    return vol_dir / f"第{chapter_num:03d}章.md"
+

+ 94 - 23
.claude/skills/webnovel-writer/scripts/context_manager.py

@@ -50,6 +50,8 @@ import sys
 import re
 from pathlib import Path
 from typing import Dict, List, Any, Optional
+from project_locator import resolve_project_root
+from chapter_paths import find_chapter_file
 
 # Windows 编码兼容性修复
 if sys.platform == 'win32':
@@ -206,22 +208,37 @@ class ContextManager:
             if i <= 0:
                 continue
 
-            chapter_file = self.chapters_dir / f"第{i:04d}章.md"
-            if not chapter_file.exists():
+            chapter_file = find_chapter_file(self.project_root, i)
+            if not chapter_file:
                 continue
 
             with open(chapter_file, 'r', encoding='utf-8') as f:
                 content = f.read()
 
-            # 提取正文(去除标题、元数据等)
-            text_match = re.search(r'---\n\n(.+)', content, re.DOTALL)
-            if text_match:
-                text = text_match.group(1).strip()
-            else:
-                text = content
+            # 提取正文摘要(避开标签/统计区块)
+            lines = content.splitlines()
+            # 去掉标题行
+            if lines and lines[0].lstrip().startswith("#"):
+                lines = lines[1:]
+
+            buf: List[str] = []
+            for line in lines:
+                s = line.strip()
+                if not s:
+                    continue
+                if s.startswith("## 本章统计"):
+                    break
+                if s == "---":
+                    continue
+                # 过滤工作流标签行([NEW_ENTITY] 等)
+                if s.startswith("[") and s.endswith("]"):
+                    continue
+                buf.append(s)
+                if sum(len(x) for x in buf) >= 220:
+                    break
 
-            # 生成摘要(取前 200 字)
-            summary = text[:200] + "..."
+            text = "".join(buf).strip()
+            summary = (text[:200] + "...") if len(text) > 200 else text
             summaries.append(f"第 {i} 章摘要:{summary}")
 
         return summaries
@@ -353,6 +370,25 @@ class ContextManager:
 
         return cards
 
+    @staticmethod
+    def _is_resolved_foreshadowing_status(raw_status: Any) -> bool:
+        """判断伏笔是否已回收(兼容历史字段与同义词)。"""
+        if raw_status is None:
+            return False
+
+        status = str(raw_status).strip()
+        if not status:
+            return False
+
+        status_lower = status.lower()
+        if status in {"已回收", "已完成", "已解决", "完成"}:
+            return True
+        if status_lower in {"resolved", "done", "complete"}:
+            return True
+        if "已回收" in status:
+            return True
+        return False
+
     def _get_relevant_foreshadowing(self, location: Optional[str],
                                    characters: Optional[List[str]]) -> List[Dict[str, str]]:
         """获取相关伏笔(优先使用索引,支持复杂条件查询)"""
@@ -396,7 +432,7 @@ class ContextManager:
         relevant = []
 
         for item in all_foreshadowing:
-            if item.get("status") != "未回收":
+            if self._is_resolved_foreshadowing_status(item.get("status")):
                 continue
 
             content = item.get("content", "")
@@ -476,27 +512,51 @@ class ContextManager:
         return "[境界划分待补充]"
 
     def _get_urgent_foreshadowing(self) -> List[str]:
-        """获取紧急伏笔(未回收 且 已埋超过 100 章)"""
+        """获取紧急伏笔(未回收 且 已埋超过 50 章)"""
         if not self.state:
             return []
 
-        current_chapter = self.state.get("progress", {}).get("current_chapter", 0)
-        all_foreshadowing = self.state.get("plot_threads", {}).get("foreshadowing", [])
+        # 优先:使用索引的紧急度(简单阈值:>50章)
+        if self.use_index and self.index:
+            try:
+                urgent_plots = self.index.query_urgent_foreshadowing(threshold=60)
+                formatted = []
+                for plot in urgent_plots:
+                    content = plot.get("content", "")
+                    if not content:
+                        continue
+                    introduced = plot.get("introduced_chapter", 0) or 0
+                    formatted.append(f"⚠️ {content}(埋设Ch{introduced})")
+                return formatted[:3]
+            except Exception as e:
+                print(f"⚠️ 伏笔索引查询失败,降级到 state.json: {e}")
 
-        urgent = []
+        current_chapter = int(self.state.get("progress", {}).get("current_chapter", 0) or 0)
+        all_foreshadowing = self.state.get("plot_threads", {}).get("foreshadowing", []) or []
 
+        scored = []
         for item in all_foreshadowing:
-            if item.get("status") != "未回收":
+            if self._is_resolved_foreshadowing_status(item.get("status")):
                 continue
 
-            # 计算已埋章节数(粗略:假设每章对应 1 个章节号增量)
-            # 实际项目中应该记录"埋设章节号"
-            # 这里简化:如果 added_at 距离现在超过 100 天,视为紧急
+            introduced = item.get("introduced_chapter") or item.get("planted_chapter") or 1
+            try:
+                introduced_chapter = int(introduced)
+            except (TypeError, ValueError):
+                introduced_chapter = 1
+
+            pending = current_chapter - introduced_chapter
+            if pending < 50:
+                continue
 
             content = item.get("content", "")
-            urgent.append(f"⚠️ {content}")
+            if not content:
+                continue
+
+            scored.append((pending, content))
 
-        return urgent[:3]  # 最多 3 条
+        scored.sort(key=lambda x: x[0], reverse=True)
+        return [f"⚠️ {content}(已埋 {pending} 章)" for pending, content in scored[:3]]
 
     def build_context(self, chapter_num: int, location: Optional[str] = None,
                      characters: Optional[List[str]] = None) -> Dict[str, Any]:
@@ -571,13 +631,21 @@ 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
+
     # 解析角色列表
     characters = None
     if args.characters:
         characters = [c.strip() for c in args.characters.split(',')]
 
     # 创建管理器
-    manager = ContextManager(args.project_root)
+    manager = ContextManager(project_root)
 
     # 加载状态
     if not manager.load_state():
@@ -594,7 +662,10 @@ def main():
         print("\n📄 上下文预览:")
         print(json.dumps(context, ensure_ascii=False, indent=2))
     else:
-        manager.save_context(context, args.output)
+        output_path = args.output
+        if args.output == '.webnovel/context_cache.json' and project_root != '.':
+            output_path = str(Path(project_root) / '.webnovel' / 'context_cache.json')
+        manager.save_context(context, output_path)
 
 if __name__ == "__main__":
     main()

+ 280 - 60
.claude/skills/webnovel-writer/scripts/extract_entities.py

@@ -24,14 +24,17 @@ import re
 import json
 import os
 import sys
+import argparse
 from pathlib import Path
 from datetime import datetime
-from typing import List, Dict, Tuple
+from typing import List, Dict, Tuple, Optional, Any
 
 # ============================================================================
 # 安全修复:导入安全工具函数(P0 CRITICAL)
 # ============================================================================
 from security_utils import sanitize_filename, create_secure_directory
+from project_locator import resolve_project_root, resolve_state_file
+from chapter_paths import find_chapter_file, extract_chapter_num_from_filename
 
 # Windows 编码兼容性修复
 if sys.platform == 'win32':
@@ -180,6 +183,69 @@ def extract_golden_finger_skills(file_path: str) -> List[Dict]:
 
     return skills
 
+
+def extract_foreshadowing_json(file_path: str) -> List[Dict[str, Any]]:
+    """
+    从章节文件提取伏笔标签(推荐放在 HTML 注释内,避免影响读者阅读):
+
+      <!-- [FORESHADOWING_JSON: {"content":"继承者验证通过","tier":"支线","target_chapter":101,"location":"云程贸易公司","characters":["陆辰"]}] -->
+
+    字段:
+      - content (必填)
+      - tier (可选: 核心/支线/装饰,默认 支线)
+      - planted_chapter (可选: 默认由调用方补齐)
+      - target_chapter (可选: 默认 planted_chapter + 100)
+      - location (可选)
+      - characters (可选: list[str] 或 逗号分隔字符串)
+    """
+    p = Path(file_path)
+    text = p.read_text(encoding="utf-8")
+
+    pattern = re.compile(r"\[FORESHADOWING_JSON:\s*(\{.*?\})\s*\]", re.DOTALL)
+    results: List[Dict[str, Any]] = []
+
+    for m in pattern.finditer(text):
+        raw = m.group(1).strip()
+        line_num = text[: m.start()].count("\n") + 1
+        try:
+            obj = json.loads(raw)
+        except json.JSONDecodeError:
+            print(f"?? 伏笔 JSON 解析失败(第{line_num}行附近),已跳过")
+            continue
+
+        content = str(obj.get("content", "")).strip()
+        if not content:
+            print(f"?? 伏笔缺少 content(第{line_num}行附近),已跳过")
+            continue
+
+        tier = str(obj.get("tier", "支线")).strip() or "支线"
+        if tier.lower() not in ENTITY_TIER_MAP:
+            tier = "支线"
+
+        characters = obj.get("characters", [])
+        if isinstance(characters, str):
+            characters_list = [c.strip() for c in re.split(r"[,,]", characters) if c.strip()]
+        elif isinstance(characters, list):
+            characters_list = [str(c).strip() for c in characters if str(c).strip()]
+        else:
+            characters_list = []
+
+        results.append(
+            {
+                "content": content,
+                "tier": tier,
+                "planted_chapter": obj.get("planted_chapter"),
+                "target_chapter": obj.get("target_chapter"),
+                "location": str(obj.get("location", "")).strip(),
+                "characters": characters_list,
+                "line": line_num,
+                "source_file": str(p),
+            }
+        )
+
+    return results
+
+
 def categorize_character(desc: str) -> str:
     """
     根据描述判断角色分类
@@ -320,11 +386,27 @@ def update_power_system(entity: Dict, target_file: str):
     with open(target_file, 'w', encoding='utf-8') as f:
         f.write(content)
 
-def update_state_json(entities: List[Dict], state_file: str, golden_finger_skills: List[Dict] = None):
-    """更新 state.json 中的实体记录(支持层级分类和金手指技能)"""
+def update_state_json(
+    entities: List[Dict],
+    state_file: str,
+    golden_finger_skills: Optional[List[Dict]] = None,
+    foreshadowing_items: Optional[List[Dict[str, Any]]] = None,
+    *,
+    default_planted_chapter: Optional[int] = None,
+):
+    """更新 state.json 中的实体记录(支持层级分类/金手指技能/伏笔结构化)"""
+
+    def _to_int(value: Any, default: int = 0) -> int:
+        try:
+            return int(value)
+        except (TypeError, ValueError):
+            return default
+
     with open(state_file, 'r', encoding='utf-8') as f:
         state = json.load(f)
 
+    first_seen_chapter = _to_int(default_planted_chapter, 0)
+
     # 确保存在实体列表
     if 'entities' not in state:
         state['entities'] = {
@@ -338,33 +420,44 @@ def update_state_json(entities: List[Dict], state_file: str, golden_finger_skill
     # 确保存在金手指技能列表
     if 'protagonist_state' not in state:
         state['protagonist_state'] = {}
-    if 'golden_finger' not in state['protagonist_state']:
-        state['protagonist_state']['golden_finger'] = {
-            "name": "",
-            "skills": [],
-            "level": 1
-        }
+    golden_finger = state['protagonist_state'].get('golden_finger')
+    if not isinstance(golden_finger, dict):
+        golden_finger = {}
+        state['protagonist_state']['golden_finger'] = golden_finger
+    golden_finger.setdefault("name", "")
+    golden_finger.setdefault("level", 1)
+    golden_finger.setdefault("cooldown", 0)
+    golden_finger.setdefault("skills", [])
 
     for entity in entities:
         entity_type = entity['type']
         entity_tier = entity.get('tier', '支线')
 
         if entity_type == "角色":
-            if entity['name'] not in [c.get('name') for c in state['entities']['characters']]:
-                state['entities']['characters'].append({
-                    "name": entity['name'],
-                    "desc": entity['desc'],
-                    "category": categorize_character(entity['desc']),
-                    "tier": entity_tier,
-                    "first_appearance": entity.get('source_file', ''),
-                    "added_at": datetime.now().strftime('%Y-%m-%d')
-                })
+            chars = state['entities']['characters']
+            record = next((c for c in chars if c.get('name') == entity['name']), None)
+            if record is None:
+                record = {"name": entity['name']}
+                chars.append(record)
+
+            desc = entity['desc']
+            record.setdefault("desc", desc)
+            record.setdefault("description", desc)  # 兼容 structured_index
+            record.setdefault("category", categorize_character(desc))
+            record.setdefault("tier", entity_tier)
+            record.setdefault("importance", "major" if entity_tier == "核心" else "minor")
+            record.setdefault("first_appearance", entity.get('source_file', ''))
+            if first_seen_chapter:
+                record.setdefault("first_appearance_chapter", first_seen_chapter)
+                record["last_appearance_chapter"] = max(_to_int(record.get("last_appearance_chapter"), 0), first_seen_chapter)
+            record.setdefault("added_at", datetime.now().strftime('%Y-%m-%d'))
 
         elif entity_type == "地点":
             if entity['name'] not in [l.get('name') for l in state['entities']['locations']]:
                 state['entities']['locations'].append({
                     "name": entity['name'],
                     "desc": entity['desc'],
+                    "description": entity['desc'],
                     "tier": entity_tier,
                     "first_appearance": entity.get('source_file', ''),
                     "added_at": datetime.now().strftime('%Y-%m-%d')
@@ -375,6 +468,7 @@ def update_state_json(entities: List[Dict], state_file: str, golden_finger_skill
                 state['entities']['items'].append({
                     "name": entity['name'],
                     "desc": entity['desc'],
+                    "description": entity['desc'],
                     "tier": entity_tier,
                     "first_appearance": entity.get('source_file', ''),
                     "added_at": datetime.now().strftime('%Y-%m-%d')
@@ -385,6 +479,7 @@ def update_state_json(entities: List[Dict], state_file: str, golden_finger_skill
                 state['entities']['factions'].append({
                     "name": entity['name'],
                     "desc": entity['desc'],
+                    "description": entity['desc'],
                     "tier": entity_tier,
                     "first_appearance": entity.get('source_file', ''),
                     "added_at": datetime.now().strftime('%Y-%m-%d')
@@ -395,6 +490,7 @@ def update_state_json(entities: List[Dict], state_file: str, golden_finger_skill
                 state['entities']['techniques'].append({
                     "name": entity['name'],
                     "desc": entity['desc'],
+                    "description": entity['desc'],
                     "tier": entity_tier,
                     "first_appearance": entity.get('source_file', ''),
                     "added_at": datetime.now().strftime('%Y-%m-%d')
@@ -415,6 +511,79 @@ def update_state_json(entities: List[Dict], state_file: str, golden_finger_skill
                 })
                 print(f"  ✨ 新增金手指技能: {skill['name']} ({skill['level']})")
 
+    # 更新伏笔(结构化)
+    if foreshadowing_items:
+        state.setdefault("plot_threads", {"active_threads": [], "foreshadowing": []})
+        state["plot_threads"].setdefault("foreshadowing", [])
+
+        existing = state["plot_threads"]["foreshadowing"]
+
+        for item in foreshadowing_items:
+            content = str(item.get("content", "")).strip()
+            if not content:
+                continue
+
+            planted = item.get("planted_chapter") or default_planted_chapter or 1
+            try:
+                planted = int(planted)
+            except (TypeError, ValueError):
+                planted = default_planted_chapter or 1
+
+            target = item.get("target_chapter")
+            if target is None:
+                target = planted + 100
+            try:
+                target = int(target)
+            except (TypeError, ValueError):
+                target = planted + 100
+
+            tier = str(item.get("tier", "支线")).strip() or "支线"
+            if tier.lower() not in ENTITY_TIER_MAP:
+                tier = "支线"
+
+            location = str(item.get("location", "")).strip()
+            characters = item.get("characters", [])
+            if not isinstance(characters, list):
+                characters = []
+
+            found = None
+            for old in existing:
+                if old.get("content") == content:
+                    found = old
+                    break
+
+            if found is None:
+                existing.append({
+                    "content": content,
+                    "status": "未回收",
+                    "tier": tier,
+                    "planted_chapter": planted,
+                    "target_chapter": target,
+                    "location": location,
+                    "characters": characters,
+                    "added_at": datetime.now().strftime("%Y-%m-%d"),
+                })
+                print(f"  ?? 新增伏笔: {content[:30]}...")
+            else:
+                found["tier"] = tier
+                found["planted_chapter"] = planted
+                found["target_chapter"] = target
+                if location:
+                    found["location"] = location
+
+                old_chars = found.get("characters", [])
+                if not isinstance(old_chars, list):
+                    old_chars = []
+                merged = []
+                seen = set()
+                for n in [*old_chars, *characters]:
+                    s = str(n).strip()
+                    if not s or s in seen:
+                        continue
+                    merged.append(s)
+                    seen.add(s)
+                found["characters"] = merged
+
     # 备份旧文件
     backup_file = state_file.replace('.json', f'.backup_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json')
     os.rename(state_file, backup_file)
@@ -539,82 +708,131 @@ def sync_entity_to_settings(entity: Dict, project_root: str, auto_mode: bool = F
         return False
 
 def main():
-    if len(sys.argv) < 2:
-        print("用法: python extract_entities.py <章节文件> [--auto] [--dry-run]")
-        print("示例: python extract_entities.py ../../../正文/第0001章.md")
-        sys.exit(1)
+    parser = argparse.ArgumentParser(
+        description="[NEW_ENTITY]/[GOLDEN_FINGER_SKILL]/FORESHADOWING_JSON 提取与同步",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+示例:
+  # 指定文件(兼容卷目录)
+  python extract_entities.py "webnovel-project/正文/第1卷/第001章-死亡降临.md" --auto
 
-    chapter_file = sys.argv[1]
-    auto_mode = '--auto' in sys.argv
-    dry_run = '--dry-run' in sys.argv
+  # 指定章节号(推荐)
+  python extract_entities.py --project-root "webnovel-project" --chapter 1 --auto
+""".strip(),
+    )
 
-    if not os.path.exists(chapter_file):
-        print(f"❌ 文件不存在: {chapter_file}")
-        sys.exit(1)
+    parser.add_argument("chapter_file", nargs="?", help="章节文件路径(或使用 --chapter)")
+    parser.add_argument("--chapter", type=int, help="章节号(与 --project-root 配合,自动定位章节文件)")
+    parser.add_argument("--project-root", default=None, help="项目根目录(包含 .webnovel/state.json)")
+    parser.add_argument("--auto", action="store_true", help="自动模式(非交互)")
+    parser.add_argument("--dry-run", action="store_true", help="仅预览,不写入文件/状态")
+
+    args = parser.parse_args()
+
+    auto_mode = args.auto
+    dry_run = args.dry_run
+
+    project_root: Optional[Path] = None
+    if args.project_root:
+        project_root = resolve_project_root(args.project_root)
+    else:
+        try:
+            project_root = resolve_project_root()
+        except FileNotFoundError:
+            project_root = None
+
+    chapter_file: Optional[str] = None
+    chapter_num: Optional[int] = None
+
+    if args.chapter is not None:
+        if not project_root:
+            print("❌ 未提供有效的 --project-root,无法用 --chapter 定位章节文件")
+            sys.exit(1)
+
+        chapter_num = int(args.chapter)
+        chapter_path = find_chapter_file(project_root, chapter_num)
+        if not chapter_path:
+            print(f"❌ 未找到第{chapter_num}章文件(请先生成/保存章节)")
+            sys.exit(1)
+        chapter_file = str(chapter_path)
+    else:
+        if not args.chapter_file:
+            parser.error("必须提供 chapter_file 或 --chapter")
+        chapter_file = args.chapter_file
+        if not os.path.exists(chapter_file):
+            print(f"❌ 文件不存在: {chapter_file}")
+            sys.exit(1)
+
+        chapter_num = extract_chapter_num_from_filename(Path(chapter_file).name)
 
-    # 提取实体
     print(f"📖 正在扫描: {chapter_file}")
     entities = extract_new_entities(chapter_file)
-
-    # 提取金手指技能
     golden_finger_skills = extract_golden_finger_skills(chapter_file)
+    foreshadowing_items = extract_foreshadowing_json(chapter_file)
 
-    if not entities and not golden_finger_skills:
-        print("✅ 未发现 [NEW_ENTITY] 或 [GOLDEN_FINGER_SKILL] 标签")
+    if not entities and not golden_finger_skills and not foreshadowing_items:
+        print("✅ 未发现 [NEW_ENTITY] / [GOLDEN_FINGER_SKILL] / [FORESHADOWING_JSON] 标签")
         return
 
     if entities:
         print(f"\n🔍 发现 {len(entities)} 个新实体:")
         for i, entity in enumerate(entities, 1):
-            tier_emoji = {"核心": "🔴", "支线": "🟡", "装饰": "🟢"}.get(entity.get('tier', '支线'), "⚪")
-            print(f"  {i}. [{entity['type']}] {entity['name']} {tier_emoji}{entity.get('tier', '支线')} - {entity['desc'][:25]}...")
+            tier_emoji = {"核心": "🔴", "支线": "🟡", "装饰": "🟢"}.get(entity.get("tier", "支线"), "⚪")
+            print(
+                f"  {i}. [{entity['type']}] {entity['name']} {tier_emoji}{entity.get('tier', '支线')} - {entity['desc'][:25]}..."
+            )
 
     if golden_finger_skills:
         print(f"\n✨ 发现 {len(golden_finger_skills)} 个金手指技能:")
         for i, skill in enumerate(golden_finger_skills, 1):
             print(f"  {i}. {skill['name']} ({skill['level']}) - {skill['desc'][:25]}...")
 
+    if foreshadowing_items:
+        print(f"\n🧩 发现 {len(foreshadowing_items)} 条伏笔:")
+        for i, item in enumerate(foreshadowing_items, 1):
+            tier = item.get("tier", "支线")
+            target = item.get("target_chapter", "未设定")
+            print(f"  {i}. {tier} → 目标Ch{target}: {str(item.get('content', ''))[:40]}...")
+
     if dry_run:
         print("\n⚠️  Dry-run 模式,不执行实际写入")
         return
 
-    # 确定项目根目录(动态查找 .webnovel/ 目录)
-    chapter_path = Path(chapter_file).resolve()
-    project_root = None
-    for parent in [chapter_path.parent] + list(chapter_path.parents):
-        if (parent / ".webnovel").exists():
-            project_root = parent
-            break
-
-    if project_root is None:
-        print(f"❌ 找不到 .webnovel 目录")
-        print(f"   搜索路径: {chapter_path.parent} 及其父目录")
-        print("请先运行 /webnovel-init 初始化项目")
-        sys.exit(1)
-
-    state_file = project_root / ".webnovel/state.json"
+    if not project_root:
+        chapter_path = Path(chapter_file).resolve()
+        for parent in [chapter_path.parent] + list(chapter_path.parents):
+            if (parent / ".webnovel" / "state.json").exists():
+                project_root = parent
+                break
 
-    if not state_file.exists():
-        print(f"❌ 状态文件不存在: {state_file}")
-        print("请先运行 /webnovel-init 初始化项目")
+    if not project_root:
+        print("❌ 找不到项目根目录(缺少 .webnovel/state.json)")
+        print("请先运行 /webnovel-init 初始化项目,或使用 --project-root 指定路径")
         sys.exit(1)
 
-    # 同步实体到设定集
-    print(f"\n📝 开始同步到设定集...")
-    success_count = 0
+    state_file = resolve_state_file(explicit_project_root=str(project_root))
 
+    print("\n📝 开始同步到设定集...")
+    success_count = 0
     for entity in entities:
         if sync_entity_to_settings(entity, str(project_root), auto_mode):
             success_count += 1
 
-    # 更新 state.json(包含金手指技能)
-    print(f"\n💾 更新 state.json...")
-    update_state_json(entities, str(state_file), golden_finger_skills)
+    print("\n💾 更新 state.json...")
+    update_state_json(
+        entities,
+        str(state_file),
+        golden_finger_skills,
+        foreshadowing_items,
+        default_planted_chapter=chapter_num,
+    )
 
-    print(f"\n✅ 完成!")
+    print("\n✅ 完成!")
     print(f"  - 实体同步: {success_count}/{len(entities)} 个")
     if golden_finger_skills:
         print(f"  - 金手指技能: {len(golden_finger_skills)} 个")
+    if foreshadowing_items:
+        print(f"  - 伏笔同步: {len(foreshadowing_items)} 条")
 
     if not auto_mode:
         print("\n💡 建议:")
@@ -623,6 +841,8 @@ def main():
         print("  3. 确认 .webnovel/state.json 中的实体记录")
         if golden_finger_skills:
             print("  4. 检查金手指技能是否正确记录在 protagonist_state.golden_finger.skills")
+        if foreshadowing_items:
+            print("  5. 检查 plot_threads.foreshadowing 的 planted/target/tier/location/characters 是否合理")
 
 if __name__ == "__main__":
     main()

+ 58 - 8
.claude/skills/webnovel-writer/scripts/golden_three_checker.py

@@ -12,17 +12,24 @@ Golden Three Chapters Checker
 
 使用方法:
 python golden_three_checker.py <章节文件路径1> <章节文件路径2> <章节文件路径3>
+python golden_three_checker.py --auto  # 自动定位前三章(推荐)
 
 示例:
-python .claude/skills/webnovel-writer/scripts/golden_three_checker.py "正文/第0001章.md" "正文/第0002章.md" "正文/第0003章.md"
+python golden_three_checker.py "正文/第1卷/第001章.md" "正文/第1卷/第002章.md" "正文/第1卷/第003章.md"
+python golden_three_checker.py --project-root "webnovel-project" --auto
 """
 
 import sys
 import os
 import re
 import json
+import argparse
 from pathlib import Path
 
+# 导入项目定位和章节路径模块
+from project_locator import resolve_project_root
+from chapter_paths import find_chapter_file
+
 # Windows UTF-8 输出修复
 if sys.platform == 'win32':
     import io
@@ -333,13 +340,56 @@ class GoldenThreeChecker:
 
 
 def main():
-    if len(sys.argv) < 4:
-        print("用法: python golden_three_checker.py <第1章路径> <第2章路径> <第3章路径>")
-        print("\n示例:")
-        print('python .claude/skills/webnovel-writer/scripts/golden_three_checker.py "正文/第0001章.md" "正文/第0002章.md" "正文/第0003章.md"')
-        sys.exit(1)
-
-    chapter_files = sys.argv[1:4]
+    parser = argparse.ArgumentParser(
+        description="黄金三章检查工具",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+示例:
+  # 指定文件路径(兼容旧格式)
+  python golden_three_checker.py "正文/第0001章.md" "正文/第0002章.md" "正文/第0003章.md"
+
+  # 自动定位前三章(推荐)
+  python golden_three_checker.py --auto
+
+  # 指定项目根目录 + 自动定位
+  python golden_three_checker.py --project-root "webnovel-project" --auto
+""".strip(),
+    )
+
+    parser.add_argument("chapter_files", nargs="*", help="前三章文件路径(可选,使用 --auto 时自动定位)")
+    parser.add_argument("--auto", action="store_true", help="自动定位前三章文件")
+    parser.add_argument("--project-root", default=None, help="项目根目录(包含 .webnovel/state.json)")
+
+    args = parser.parse_args()
+
+    chapter_files = []
+
+    if args.auto or not args.chapter_files:
+        # 自动定位前三章
+        try:
+            project_root = resolve_project_root(args.project_root)
+        except FileNotFoundError as e:
+            print(f"❌ {e}")
+            sys.exit(1)
+
+        for i in range(1, 4):
+            chapter_path = find_chapter_file(project_root, i)
+            if chapter_path:
+                chapter_files.append(str(chapter_path))
+            else:
+                print(f"❌ 找不到第 {i} 章文件")
+                sys.exit(1)
+
+        print(f"📂 项目根目录: {project_root}")
+        print(f"📄 检测到前三章: {', '.join(Path(f).name for f in chapter_files)}\n")
+    else:
+        if len(args.chapter_files) < 3:
+            print("用法: python golden_three_checker.py <第1章路径> <第2章路径> <第3章路径>")
+            print("  或: python golden_three_checker.py --auto")
+            print("\n示例:")
+            print('python golden_three_checker.py "正文/第1卷/第001章.md" "正文/第1卷/第002章.md" "正文/第1卷/第003章.md"')
+            sys.exit(1)
+        chapter_files = args.chapter_files[:3]
 
     try:
         checker = GoldenThreeChecker(chapter_files)

+ 426 - 129
.claude/skills/webnovel-writer/scripts/init_project.py

@@ -2,115 +2,386 @@
 # -*- coding: utf-8 -*-
 """
 网文项目初始化脚本
-创建 AI 工作室所需的完整项目结构
+
+目标:
+- 生成可运行的项目结构(webnovel-project)
+- 创建/更新 .webnovel/state.json(运行时真相)
+- 生成基础设定集与大纲模板文件(供 /webnovel-plan 与 /webnovel-write 使用)
+
+说明:
+- 该脚本是命令 /webnovel-init 的“唯一允许的文件生成入口”(与命令文档保持一致)。
+- 生成的内容以“模板骨架”为主,便于 AI/作者后续补全;但保证所有关键文件存在。
 """
 
-import os
-import sys
+from __future__ import annotations
+
+import argparse
 import json
 import subprocess
-from pathlib import Path
+import sys
 from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict
+
 
 # Windows 编码兼容性修复
-if sys.platform == 'win32':
+if sys.platform == "win32":
     import io
-    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
-    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
 
-def init_project(project_dir, title, genre):
-    """初始化项目结构"""
+    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
+    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
+
 
-    project_path = Path(project_dir)
+def _read_text_if_exists(path: Path) -> str:
+    if not path.exists():
+        return ""
+    return path.read_text(encoding="utf-8")
 
-    # 创建目录结构
+
+def _write_text_if_missing(path: Path, content: str) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    if path.exists():
+        return
+    path.write_text(content, encoding="utf-8")
+
+
+def _ensure_state_schema(state: Dict[str, Any]) -> Dict[str, Any]:
+    """确保 state.json 具备脚本族所需的最低字段集合(兼容旧版本)。"""
+    state.setdefault("project_info", {})
+    state.setdefault("progress", {})
+    state.setdefault("protagonist_state", {})
+    state.setdefault("relationships", {})
+    state.setdefault("world_settings", {"power_system": [], "factions": [], "locations": []})
+    state.setdefault("plot_threads", {"active_threads": [], "foreshadowing": []})
+    state.setdefault("review_checkpoints", [])
+    state.setdefault(
+        "strand_tracker",
+        {
+            "last_quest_chapter": 0,
+            "last_fire_chapter": 0,
+            "last_constellation_chapter": 0,
+            "current_dominant": "quest",
+            "chapters_since_switch": 0,
+            "history": [],
+        },
+    )
+    state.setdefault(
+        "entities",
+        {"characters": [], "locations": [], "items": [], "factions": [], "techniques": []},
+    )
+
+    # progress schema evolution
+    state["progress"].setdefault("current_chapter", 0)
+    state["progress"].setdefault("total_words", 0)
+    state["progress"].setdefault("last_updated", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
+    state["progress"].setdefault("volumes_completed", [])
+    state["progress"].setdefault("current_volume", 1)
+    state["progress"].setdefault("volumes_planned", [])
+
+    # protagonist schema evolution
+    ps = state["protagonist_state"]
+    ps.setdefault("name", "")
+    ps.setdefault("power", {"realm": "", "layer": 1, "bottleneck": ""})
+    ps.setdefault("location", {"current": "", "last_chapter": 0})
+    ps.setdefault("golden_finger", {"name": "", "level": 1, "cooldown": 0, "skills": []})
+    ps.setdefault("attributes", {})
+
+    return state
+
+
+def _build_master_outline(target_chapters: int, *, chapters_per_volume: int = 50) -> str:
+    volumes = (target_chapters - 1) // chapters_per_volume + 1 if target_chapters > 0 else 1
+    lines: list[str] = [
+        "# 总纲",
+        "",
+        "> 本文件为“总纲骨架”,用于 /webnovel-plan 细化为卷大纲与章纲。",
+        "",
+        "## 卷结构",
+        "",
+    ]
+
+    for v in range(1, volumes + 1):
+        start = (v - 1) * chapters_per_volume + 1
+        end = min(v * chapters_per_volume, target_chapters)
+        lines.extend(
+            [
+                f"### 第{v}卷(第{start}-{end}章)",
+                "- 核心冲突:",
+                "- 关键爽点:",
+                "- 卷末高潮:",
+                "- 主要登场角色:",
+                "- 关键伏笔(埋/收):",
+                "",
+            ]
+        )
+
+    return "\n".join(lines).rstrip() + "\n"
+
+
+def init_project(
+    project_dir: str,
+    title: str,
+    genre: str,
+    *,
+    protagonist_name: str = "",
+    target_words: int = 2_000_000,
+    target_chapters: int = 600,
+    golden_finger_name: str = "",
+    golden_finger_type: str = "",
+    golden_finger_style: str = "",
+    core_selling_points: str = "",
+    protagonist_desire: str = "",
+    protagonist_flaw: str = "",
+    protagonist_archetype: str = "",
+    antagonist_level: str = "",
+    target_reader: str = "",
+    platform: str = "",
+) -> None:
+    project_path = Path(project_dir).expanduser().resolve()
+    project_path.mkdir(parents=True, exist_ok=True)
+
+    # 目录结构(同时兼容“卷目录”与后续扩展)
     directories = [
         ".webnovel/backups",
+        ".webnovel/archive",
         "设定集/角色库/主要角色",
         "设定集/角色库/次要角色",
         "设定集/角色库/反派角色",
+        "设定集/物品库",
+        "设定集/其他设定",
         "大纲",
-        "正文",
-        "审查报告"
+        "正文/第1卷",
+        "审查报告",
     ]
-
     for dir_path in directories:
         (project_path / dir_path).mkdir(parents=True, exist_ok=True)
 
-    # 创建 state.json(动态状态管理)
-    state = {
-        "project_info": {
+    # state.json(创建或增量补齐)
+    state_path = project_path / ".webnovel" / "state.json"
+    if state_path.exists():
+        try:
+            state: Dict[str, Any] = json.loads(state_path.read_text(encoding="utf-8"))
+        except json.JSONDecodeError:
+            state = {}
+    else:
+        state = {}
+
+    state = _ensure_state_schema(state)
+    created_at = state.get("project_info", {}).get("created_at") or datetime.now().strftime("%Y-%m-%d")
+
+    state["project_info"].update(
+        {
             "title": title,
             "genre": genre,
-            "author": "",
-            "created_at": datetime.now().strftime("%Y-%m-%d"),
-            "target_words": 2000000,
-            "target_chapters": 600
-        },
-        "progress": {
-            "current_chapter": 0,
-            "total_words": 0,
-            "last_updated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
-            "volumes_completed": [],
-            "current_volume": 1
-        },
-        "protagonist_state": {
-            "name": "",
-            "power": {
-                "realm": "",
-                "layer": 1,
-                "bottleneck": ""
-            },
-            "location": {
-                "current": "",
-                "last_chapter": 0
-            },
-            "golden_finger": {
-                "name": "",
-                "level": 1,
-                "cooldown": 0
-            }
-        },
-        "relationships": {},
-        "world_settings": {
-            "power_system": [],
-            "factions": [],
-            "locations": []
-        },
-        "plot_threads": {
-            "active_threads": [],
-            "foreshadowing": []
-        },
-        "strand_tracker": {
-            "last_quest_chapter": 0,
-            "last_fire_chapter": 0,
-            "last_constellation_chapter": 0,
-            "current_dominant": "quest",
-            "chapters_since_switch": 0,
-            "history": []
-        },
-        "review_checkpoints": []
-    }
-
-    with open(project_path / ".webnovel/state.json", "w", encoding="utf-8") as f:
-        json.dump(state, f, ensure_ascii=False, indent=2)
-
-    # Git 初始化(自动版本控制)
-    print("\n🔧 正在初始化 Git 仓库...")
-    try:
-        # Step 1: git init
-        subprocess.run(
-            ["git", "init"],
-            cwd=project_path,
-            check=True,
-            capture_output=True,
-            text=True
+            "created_at": created_at,
+            "target_words": int(target_words),
+            "target_chapters": int(target_chapters),
+            # 下面字段属于“初始化元信息”,不影响运行时脚本
+            "golden_finger_name": golden_finger_name,
+            "golden_finger_type": golden_finger_type,
+            "golden_finger_style": golden_finger_style,
+            "core_selling_points": core_selling_points,
+            "target_reader": target_reader,
+            "platform": platform,
+        }
+    )
+
+    if protagonist_name:
+        state["protagonist_state"]["name"] = protagonist_name
+
+    if golden_finger_name:
+        state["protagonist_state"]["golden_finger"]["name"] = golden_finger_name
+
+    # 确保 golden_finger 字段存在且可编辑
+    if not state["protagonist_state"]["golden_finger"].get("name"):
+        state["protagonist_state"]["golden_finger"]["name"] = "未命名金手指"
+
+    state["progress"]["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+    state_path.parent.mkdir(parents=True, exist_ok=True)
+    state_path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
+
+    # 读取内置模板(可选)
+    script_dir = Path(__file__).resolve().parent
+    templates_dir = script_dir.parent / "templates"
+    genre_key = (genre or "").strip()
+    genre_template_key = {
+        "修仙/玄幻": "修仙",
+        "玄幻": "修仙",
+    }.get(genre_key, genre_key)
+    genre_template = _read_text_if_exists(templates_dir / "genres" / f"{genre_template_key}.md")
+    golden_finger_templates = _read_text_if_exists(templates_dir / "golden-finger-templates.md")
+
+    # 基础文件(只在缺失时生成,避免覆盖已有内容)
+    now = datetime.now().strftime("%Y-%m-%d")
+
+    _write_text_if_missing(
+        project_path / "设定集" / "世界观.md",
+        "\n".join(
+            [
+                "# 世界观",
+                "",
+                f"> 项目:{title}|题材:{genre}|创建:{now}",
+                "",
+                "## 一句话世界观",
+                "- (用一句话说明世界的核心规则与卖点)",
+                "",
+                "## 核心规则(设定即物理)",
+                "- 规则1:",
+                "- 规则2:",
+                "- 规则3:",
+                "",
+                "## 势力与地理(简版)",
+                "- 主要势力:",
+                "- 关键地点:",
+                "",
+                "## 参考题材模板(可删/可改)",
+                "",
+                (genre_template.strip() + "\n") if genre_template else "(未找到对应题材模板,可自行补充)\n",
+            ]
+        ),
+    )
+
+    _write_text_if_missing(
+        project_path / "设定集" / "力量体系.md",
+        "\n".join(
+            [
+                "# 力量体系",
+                "",
+                f"> 项目:{title}|题材:{genre}|创建:{now}",
+                "",
+                "## 等级/境界划分",
+                "- (列出从弱到强的等级,含突破条件与代价)",
+                "",
+                "## 技能/招式规则",
+                "- 获得方式:",
+                "- 成本与副作用:",
+                "- 进阶与组合:",
+                "",
+                "## 禁止事项(防崩坏)",
+                "- 未达等级不得使用高阶能力(设定即物理)",
+                "- 新增能力必须申报并入库(发明需申报)",
+                "",
+            ]
+        ),
+    )
+
+    _write_text_if_missing(
+        project_path / "设定集" / "主角卡.md",
+        "\n".join(
+            [
+                "# 主角卡",
+                "",
+                f"> 主角:{protagonist_name or '(待填写)'}|项目:{title}|创建:{now}",
+                "",
+                "## 三要素",
+                f"- 欲望:{protagonist_desire or '(待填写)'}",
+                f"- 弱点:{protagonist_flaw or '(待填写)'}",
+                f"- 人设类型:{protagonist_archetype or '(待填写)'}",
+                "",
+                "## 初始状态(开局)",
+                "- 身份:",
+                "- 资源:",
+                "- 约束:",
+                "",
+                "## 金手指概览",
+                f"- 称呼:{golden_finger_name or '(待填写)'}",
+                f"- 类型:{golden_finger_type or '(待填写)'}",
+                f"- 风格:{golden_finger_style or '(待填写)'}",
+                "- 成长曲线:",
+                "",
+            ]
+        ),
+    )
+
+    _write_text_if_missing(
+        project_path / "设定集" / "金手指设计.md",
+        "\n".join(
+            [
+                "# 金手指设计",
+                "",
+                f"> 项目:{title}|题材:{genre}|创建:{now}",
+                "",
+                "## 选型",
+                f"- 称呼:{golden_finger_name or '(待填写)'}",
+                f"- 类型:{golden_finger_type or '(待填写)'}",
+                f"- 风格:{golden_finger_style or '(待填写)'}",
+                "",
+                "## 规则(必须写清)",
+                "- 触发条件:",
+                "- 冷却/代价:",
+                "- 上限:",
+                "- 反噬/风险:",
+                "",
+                "## 成长曲线(章节规划)",
+                "- Lv1:",
+                "- Lv2:",
+                "- Lv3:",
+                "",
+                "## 模板参考(可删/可改)",
+                "",
+                (golden_finger_templates.strip() + "\n") if golden_finger_templates else "(未找到金手指模板库)\n",
+            ]
+        ),
+    )
+
+    if antagonist_level:
+        _write_text_if_missing(
+            project_path / "设定集" / "反派设计.md",
+            "\n".join(
+                [
+                    "# 反派设计",
+                    "",
+                    f"> 项目:{title}|创建:{now}",
+                    "",
+                    f"- 反派等级:{antagonist_level}",
+                    "- 动机:",
+                    "- 资源/势力:",
+                    "- 与主角的镜像关系:",
+                    "- 终局:",
+                    "",
+                ]
+            ),
         )
 
-        # Step 2: 创建 .gitignore
-        gitignore_file = project_path / ".gitignore"
-        with open(gitignore_file, 'w', encoding='utf-8') as f:
-            f.write("""# Python
+    _write_text_if_missing(project_path / "大纲" / "总纲.md", _build_master_outline(int(target_chapters)))
+
+    _write_text_if_missing(
+        project_path / "大纲" / "爽点规划.md",
+        "\n".join(
+            [
+                "# 爽点规划",
+                "",
+                f"> 项目:{title}|题材:{genre}|创建:{now}",
+                "",
+                "## 核心卖点(来自初始化输入)",
+                f"- {core_selling_points or '(待填写,建议 1-3 条,用逗号分隔)'}",
+                "",
+                "## 密度目标(建议)",
+                "- 每章至少 1 个小爽点",
+                "- 每 5 章至少 1 个大爽点",
+                "",
+                "## 分布表(示例,可改)",
+                "",
+                "| 章节范围 | 主导爽点类型 | 备注 |",
+                "|---|---|---|",
+                "| 1-5 | 金手指/打脸/反转 | 开篇钩子 + 立人设 |",
+                "| 6-10 | 升级/收获 | 进入主线节奏 |",
+                "",
+            ]
+        ),
+    )
+
+    # Git 初始化(仅当项目目录内尚无 .git)
+    git_dir = project_path / ".git"
+    if not git_dir.exists():
+        print("\nInitializing Git repository...")
+        try:
+            subprocess.run(["git", "init"], cwd=project_path, check=True, capture_output=True, text=True)
+
+            gitignore_file = project_path / ".gitignore"
+            if not gitignore_file.exists():
+                gitignore_file.write_text(
+                    """# Python
 __pycache__/
 *.py[cod]
 *.so
@@ -127,52 +398,78 @@ __pycache__/
 # Don't ignore .webnovel (we need to track state.json)
 # But ignore cache files
 .webnovel/context_cache.json
-""")
-
-        # Step 3: git add .
-        subprocess.run(
-            ["git", "add", "."],
-            cwd=project_path,
-            check=True,
-            capture_output=True
-        )
+""",
+                    encoding="utf-8",
+                )
 
-        # Step 4: Initial commit
-        subprocess.run(
-            ["git", "commit", "-m", "Initial commit: Project initialized"],
-            cwd=project_path,
-            check=True,
-            capture_output=True
-        )
+            subprocess.run(["git", "add", "."], cwd=project_path, check=True, capture_output=True)
+            subprocess.run(
+                ["git", "commit", "-m", f"初始化网文项目:{title}"],
+                cwd=project_path,
+                check=True,
+                capture_output=True,
+            )
+            print("Git initialized.")
+        except FileNotFoundError:
+            print("Git not found; skipping Git initialization.")
+        except subprocess.CalledProcessError as e:
+            print(f"Git init failed (non-fatal): {e}")
 
-        print("✅ Git 仓库已初始化(原子性版本控制已启用)")
-
-    except subprocess.CalledProcessError as e:
-        print(f"⚠️  Git 初始化失败(非致命): {e.stderr if hasattr(e, 'stderr') else str(e)}")
-        print("💡 提示:可以稍后手动执行 git init")
-    except FileNotFoundError:
-        print("⚠️  未检测到 Git(请安装 Git 以启用版本控制功能)")
-
-    print(f"\n✅ 项目初始化完成:{project_path}")
-    print(f"📁 项目目录已创建")
-    print(f"💾 状态文件已保存:.webnovel/state.json")
-    print(f"🔖 Git 版本控制已启用")
-    print(f"\n📚 题材:{genre}")
-    print(f"📖 标题:{title}")
-    print(f"\n🎯 下一步:")
-    print(f"   1. 编辑设定集中的文件")
-    print(f"   2. 运行 /webnovel-plan 1 规划第一卷")
-    print(f"   3. 运行 /webnovel-write 1 开始创作")
-    print(f"\n💾 版本控制提示:")
-    print(f"   - 每章完成后自动 Git 备份")
-    print(f"   - 回滚命令:python backup_manager.py --rollback N")
-    print(f"   - 查看历史:python backup_manager.py --list")
+    print(f"\nProject initialized at: {project_path}")
+    print("Key files:")
+    print(" - .webnovel/state.json")
+    print(" - 设定集/世界观.md")
+    print(" - 设定集/力量体系.md")
+    print(" - 设定集/主角卡.md")
+    print(" - 设定集/金手指设计.md")
+    print(" - 大纲/总纲.md")
+    print(" - 大纲/爽点规划.md")
 
-if __name__ == "__main__":
-    import sys
-    if len(sys.argv) < 4:
-        print("用法: python init_project.py <项目目录> <标题> <题材>")
-        print("示例: python init_project.py ./my-novel '废柴崛起' '修仙'")
-        sys.exit(1)
 
-    init_project(sys.argv[1], sys.argv[2], sys.argv[3])
+def main() -> None:
+    parser = argparse.ArgumentParser(description="网文项目初始化脚本(生成项目结构 + state.json + 基础模板)")
+    parser.add_argument("project_dir", help="项目目录(建议 ./webnovel-project)")
+    parser.add_argument("title", help="小说标题")
+    parser.add_argument("genre", help="题材类型(如:修仙/系统流/都市异能/狗血言情/古言/现实题材/规则怪谈/知乎短篇)")
+
+    parser.add_argument("--protagonist-name", default="", help="主角姓名")
+    parser.add_argument("--target-words", type=int, default=2_000_000, help="目标总字数(默认 2000000)")
+    parser.add_argument("--target-chapters", type=int, default=600, help="目标总章节数(默认 600)")
+
+    parser.add_argument("--golden-finger-name", default="", help="金手指称呼/系统名(建议读者可见的代号)")
+    parser.add_argument("--golden-finger-type", default="", help="金手指类型(如 系统流/鉴定流/签到流)")
+    parser.add_argument("--golden-finger-style", default="", help="金手指风格(如 冷漠工具型/毒舌吐槽型)")
+    parser.add_argument("--core-selling-points", default="", help="核心卖点(逗号分隔)")
+
+    # 深度模式可选参数(用于预填模板)
+    parser.add_argument("--protagonist-desire", default="", help="主角核心欲望(深度模式)")
+    parser.add_argument("--protagonist-flaw", default="", help="主角性格弱点(深度模式)")
+    parser.add_argument("--protagonist-archetype", default="", help="主角人设类型(深度模式)")
+    parser.add_argument("--antagonist-level", default="", help="反派等级(深度模式)")
+    parser.add_argument("--target-reader", default="", help="目标读者(深度模式)")
+    parser.add_argument("--platform", default="", help="发布平台(深度模式)")
+
+    args = parser.parse_args()
+
+    init_project(
+        args.project_dir,
+        args.title,
+        args.genre,
+        protagonist_name=args.protagonist_name,
+        target_words=args.target_words,
+        target_chapters=args.target_chapters,
+        golden_finger_name=args.golden_finger_name,
+        golden_finger_type=args.golden_finger_type,
+        golden_finger_style=args.golden_finger_style,
+        core_selling_points=args.core_selling_points,
+        protagonist_desire=args.protagonist_desire,
+        protagonist_flaw=args.protagonist_flaw,
+        protagonist_archetype=args.protagonist_archetype,
+        antagonist_level=args.antagonist_level,
+        target_reader=args.target_reader,
+        platform=args.platform,
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 94 - 0
.claude/skills/webnovel-writer/scripts/project_locator.py

@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""
+Project location helpers for webnovel-writer scripts.
+
+Problem this solves:
+- Many scripts assumed CWD is the project root and used relative paths like `.webnovel/state.json`.
+- In this repo, commands/scripts are often invoked from the repo root, while the actual project lives
+  in a subdirectory (default: `webnovel-project/`).
+
+These helpers provide a single, consistent way to locate the active project root.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Iterable, Optional
+
+
+DEFAULT_PROJECT_DIR_NAMES: tuple[str, ...] = ("webnovel-project",)
+
+
+def _candidate_roots(cwd: Path) -> Iterable[Path]:
+    yield cwd
+    for name in DEFAULT_PROJECT_DIR_NAMES:
+        yield cwd / name
+
+    for parent in cwd.parents:
+        yield parent
+        for name in DEFAULT_PROJECT_DIR_NAMES:
+            yield parent / name
+
+
+def _is_project_root(path: Path) -> bool:
+    return (path / ".webnovel" / "state.json").is_file()
+
+
+def resolve_project_root(explicit_project_root: Optional[str] = None, *, cwd: Optional[Path] = None) -> Path:
+    """
+    Resolve the webnovel project root directory (the directory containing `.webnovel/state.json`).
+
+    Resolution order:
+    1) explicit_project_root (if provided)
+    2) env var WEBNOVEL_PROJECT_ROOT (if set)
+    3) Search from cwd and parents, including common subdir `webnovel-project/`
+
+    Raises:
+        FileNotFoundError: if no valid project root can be found.
+    """
+    if explicit_project_root:
+        root = Path(explicit_project_root).expanduser().resolve()
+        if _is_project_root(root):
+            return 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()
+        if _is_project_root(root):
+            return root
+        raise FileNotFoundError(f"WEBNOVEL_PROJECT_ROOT is set but invalid (missing .webnovel/state.json): {root}")
+
+    base = (cwd or Path.cwd()).resolve()
+    for candidate in _candidate_roots(base):
+        if _is_project_root(candidate):
+            return candidate.resolve()
+
+    raise FileNotFoundError(
+        "Unable to locate webnovel project root. Expected `.webnovel/state.json` under the current directory, "
+        "a parent directory, or `webnovel-project/`. Run /webnovel-init first or pass --project-root / set "
+        "WEBNOVEL_PROJECT_ROOT."
+    )
+
+
+def resolve_state_file(
+    explicit_state_file: Optional[str] = None,
+    *,
+    explicit_project_root: Optional[str] = None,
+    cwd: Optional[Path] = None,
+) -> Path:
+    """
+    Resolve `.webnovel/state.json` path.
+
+    If explicit_state_file is provided, returns it as-is (resolved to absolute if relative).
+    Otherwise derives it from resolve_project_root().
+    """
+    base = (cwd or Path.cwd()).resolve()
+    if explicit_state_file:
+        p = Path(explicit_state_file).expanduser()
+        return (base / p).resolve() if not p.is_absolute() else p.resolve()
+
+    root = resolve_project_root(explicit_project_root, cwd=base)
+    return root / ".webnovel" / "state.json"
+

+ 116 - 16
.claude/skills/webnovel-writer/scripts/status_reporter.py

@@ -86,6 +86,26 @@ from pathlib import Path
 from typing import Dict, List, Any, Tuple
 from datetime import datetime
 from collections import defaultdict
+from project_locator import resolve_project_root
+from chapter_paths import extract_chapter_num_from_filename
+
+def _is_resolved_foreshadowing_status(raw_status: Any) -> bool:
+    """判断伏笔是否已回收(兼容历史字段与同义词)。"""
+    if raw_status is None:
+        return False
+
+    status = str(raw_status).strip()
+    if not status:
+        return False
+
+    status_lower = status.lower()
+    if status in {"已回收", "已完成", "已解决", "完成"}:
+        return True
+    if status_lower in {"resolved", "done", "complete"}:
+        return True
+    if "已回收" in status:
+        return True
+    return False
 
 # Windows 编码兼容性修复
 if sys.platform == 'win32':
@@ -104,6 +124,26 @@ class StatusReporter:
         self.state = None
         self.chapters_data = []
 
+        # 可选:集成结构化索引(如果可用,角色统计更准)
+        self.index = None
+        try:
+            from structured_index import StructuredIndex
+            self.index = StructuredIndex(self.project_root)
+        except Exception:
+            self.index = None
+
+    def _extract_stats_field(self, content: str, field_name: str) -> str:
+        """
+        从“本章统计”区块提取字段值,例如:
+        - **主导Strand**: quest
+        """
+        pattern = rf"^\s*-\s*\*\*{re.escape(field_name)}\*\*\s*:\s*(.+?)\s*$"
+        for line in content.splitlines():
+            m = re.match(pattern, line)
+            if m:
+                return m.group(1).strip()
+        return ""
+
     def load_state(self) -> bool:
         """加载 state.json"""
         if not self.state_file.exists():
@@ -121,14 +161,26 @@ class StatusReporter:
             print(f"⚠️  正文目录不存在: {self.chapters_dir}")
             return
 
-        for chapter_file in sorted(self.chapters_dir.glob("第*.md")):
-            # 提取章节号
-            match = re.search(r'第(\d+)章', chapter_file.name)
-            if not match:
+        # 支持两种目录结构:
+        # 1) 正文/第0001章.md
+        # 2) 正文/第1卷/第001章-标题.md
+        chapter_files = sorted(self.chapters_dir.rglob("第*.md"))
+
+        # 角色候选(fallback 用):从 state.json 获取已知角色名
+        known_character_names: List[str] = []
+        protagonist_name = ""
+        if self.state:
+            protagonist_name = self.state.get("protagonist_state", {}).get("name", "") or ""
+            known_character_names = [
+                c.get("name", "") for c in self.state.get("entities", {}).get("characters", [])
+                if c.get("name")
+            ]
+
+        for chapter_file in chapter_files:
+            chapter_num = extract_chapter_num_from_filename(chapter_file.name)
+            if not chapter_num:
                 continue
 
-            chapter_num = int(match.group(1))
-
             # 读取章节内容
             with open(chapter_file, 'r', encoding='utf-8') as f:
                 content = f.read()
@@ -139,14 +191,51 @@ class StatusReporter:
             text = re.sub(r'---', '', text)  # 去除分隔线
             word_count = len(text.strip())
 
-            # 提取出场角色(粗略:查找 [角色: XXX])
-            characters = re.findall(r'\[角色:\s*([^\]]+)\]', content)
+            # 主导 Strand / 爽点类型(优先从“本章统计”解析)
+            dominant_strand = (self._extract_stats_field(content, "主导Strand") or "").lower()
+            cool_point_type = self._extract_stats_field(content, "爽点")
+
+            # 角色提取:优先从结构化索引读取(若有),否则 fallback 用“出现即算出场”
+            characters: List[str] = []
+            if self.index is not None:
+                try:
+                    cursor = self.index.conn.execute(
+                        "SELECT characters FROM chapters WHERE chapter_num = ?",
+                        (chapter_num,),
+                    )
+                    row = cursor.fetchone()
+                    if row and row[0]:
+                        try:
+                            stored = json.loads(row[0])
+                            if isinstance(stored, list):
+                                characters = [str(x) for x in stored if x]
+                        except json.JSONDecodeError:
+                            characters = []
+                except Exception:
+                    characters = []
+
+            if not characters and (protagonist_name or known_character_names):
+                # 限制候选规模,避免在超大角色库下过慢
+                candidates = []
+                if protagonist_name:
+                    candidates.append(protagonist_name)
+                candidates.extend(known_character_names[:800])
+
+                seen = set()
+                for name in candidates:
+                    if not name or name in seen:
+                        continue
+                    if name in content:
+                        characters.append(name)
+                        seen.add(name)
 
             self.chapters_data.append({
                 "chapter": chapter_num,
                 "file": chapter_file,
                 "word_count": word_count,
-                "characters": characters
+                "characters": characters,
+                "dominant": dominant_strand,
+                "cool_point": cool_point_type,
             })
 
     def analyze_characters(self) -> Dict:
@@ -206,7 +295,8 @@ class StatusReporter:
         overdue = []
 
         for item in foreshadowing:
-            if item.get("status") != "未回收":
+            status = item.get("status")
+            if _is_resolved_foreshadowing_status(status):
                 continue
 
             # 假设每个伏笔记录了"added_chapter"(埋设章节)
@@ -273,7 +363,7 @@ class StatusReporter:
         urgency_list = []
 
         for item in foreshadowing:
-            if item.get("status") == "已回收":
+            if _is_resolved_foreshadowing_status(item.get("status")):
                 continue
 
             content = item.get("content", "")
@@ -350,7 +440,7 @@ class StatusReporter:
         total = len(history)
 
         for entry in history:
-            strand = entry.get("strand", "").lower()
+            strand = (entry.get("strand") or entry.get("dominant") or "").lower()
             if strand in ["quest", "主线", "战斗", "任务"]:
                 quest_count += 1
             elif strand in ["fire", "感情", "感情线", "互动"]:
@@ -370,7 +460,7 @@ class StatusReporter:
         quest_streak = 0
         max_quest_streak = 0
         for entry in history:
-            strand = entry.get("strand", "").lower()
+            strand = (entry.get("strand") or entry.get("dominant") or "").lower()
             if strand in ["quest", "主线", "战斗", "任务"]:
                 quest_streak += 1
                 max_quest_streak = max(max_quest_streak, quest_streak)
@@ -384,7 +474,7 @@ class StatusReporter:
         fire_gap = 0
         max_fire_gap = 0
         for entry in history:
-            strand = entry.get("strand", "").lower()
+            strand = (entry.get("strand") or entry.get("dominant") or "").lower()
             if strand in ["fire", "感情", "感情线", "互动"]:
                 max_fire_gap = max(max_fire_gap, fire_gap)
                 fire_gap = 0
@@ -399,7 +489,7 @@ class StatusReporter:
         const_gap = 0
         max_const_gap = 0
         for entry in history:
-            strand = entry.get("strand", "").lower()
+            strand = (entry.get("strand") or entry.get("dominant") or "").lower()
             if strand in ["constellation", "世界观", "背景", "势力"]:
                 max_const_gap = max(max_const_gap, const_gap)
                 const_gap = 0
@@ -829,8 +919,16 @@ 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
+
     # 创建报告生成器
-    reporter = StatusReporter(args.project_root)
+    reporter = StatusReporter(project_root)
 
     # 加载状态
     if not reporter.load_state():
@@ -848,6 +946,8 @@ def main():
 
     # 保存报告
     output_file = Path(args.output)
+    if args.output == '.webnovel/health_report.md' and project_root != '.':
+        output_file = Path(project_root) / '.webnovel' / 'health_report.md'
     output_file.parent.mkdir(parents=True, exist_ok=True)
 
     with open(output_file, 'w', encoding='utf-8') as f:

+ 166 - 21
.claude/skills/webnovel-writer/scripts/structured_index.py

@@ -41,6 +41,7 @@ import argparse
 import sqlite3
 import hashlib
 import re
+import tempfile
 from datetime import datetime
 from pathlib import Path
 from typing import Optional, List, Dict, Tuple
@@ -49,6 +50,8 @@ from typing import Optional, List, Dict, Tuple
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # ============================================================================
 from security_utils import create_secure_directory
+from project_locator import resolve_project_root
+from chapter_paths import find_chapter_file
 
 
 class StructuredIndex:
@@ -56,7 +59,10 @@ class StructuredIndex:
 
     def __init__(self, project_root=None):
         if project_root is None:
-            project_root = Path.cwd()
+            try:
+                project_root = resolve_project_root()
+            except FileNotFoundError:
+                project_root = Path.cwd()
         else:
             project_root = Path(project_root)
 
@@ -214,6 +220,87 @@ class StructuredIndex:
         self.conn.commit()
         print(f"✅ 章节索引已更新:Ch{chapter_num} - {metadata['title']}")
 
+    def bump_character_last_appearance_in_state(self, chapter_num: int, character_names: List[str]) -> int:
+        """将本章出场角色同步回 state.json 的 last_appearance_chapter(轻量级)"""
+        if not character_names:
+            return 0
+        if not self.state_file.exists():
+            return 0
+
+        try:
+            with open(self.state_file, 'r', encoding='utf-8') as f:
+                state = json.load(f)
+        except json.JSONDecodeError:
+            return 0
+
+        entities = state.get("entities", {}) or {}
+        characters = entities.get("characters", [])
+        if not isinstance(characters, list):
+            return 0
+
+        name_set = {str(n).strip() for n in character_names if str(n).strip()}
+        if not name_set:
+            return 0
+
+        updated = 0
+        changed = False
+        for char in characters:
+            if not isinstance(char, dict):
+                continue
+            name = str(char.get("name", "")).strip()
+            if not name or name not in name_set:
+                continue
+
+            tier = str(char.get("tier", "")).strip()
+            if "importance" not in char:
+                char["importance"] = "major" if tier == "核心" else "minor"
+                changed = True
+
+            if "description" not in char and isinstance(char.get("desc"), str):
+                char["description"] = char.get("desc", "")
+                changed = True
+
+            prev = char.get("last_appearance_chapter")
+            try:
+                prev_int = int(prev)
+            except (TypeError, ValueError):
+                prev_int = 0
+
+            new_last = max(prev_int, int(chapter_num))
+            if new_last != prev_int:
+                char["last_appearance_chapter"] = new_last
+                updated += 1
+                changed = True
+
+            if not char.get("first_appearance_chapter"):
+                char["first_appearance_chapter"] = int(chapter_num)
+                changed = True
+
+        if not changed:
+            return 0
+
+        tmp_path = None
+        try:
+            with tempfile.NamedTemporaryFile(
+                mode="w",
+                encoding="utf-8",
+                suffix=".tmp",
+                delete=False,
+                dir=str(self.state_file.parent),
+            ) as tf:
+                tmp_path = Path(tf.name)
+                json.dump(state, tf, ensure_ascii=False, indent=2)
+                tf.write("\n")
+            os.replace(str(tmp_path), str(self.state_file))
+        finally:
+            if tmp_path and tmp_path.exists():
+                try:
+                    tmp_path.unlink()
+                except OSError:
+                    pass
+
+        return updated
+
     def query_chapters_by_location(self, location: str, limit: int = 10) -> List[Tuple]:
         """O(log n) 查询:返回该地点的最近 N 章
 
@@ -259,9 +346,8 @@ class StructuredIndex:
         - 增加耗时:~5ms(Hash 计算 + 对比)
         - 仅当检测到变更时才重建(增量成本)
         """
-        chapter_file = self.chapters_dir / f"第{chapter_num:04d}章.md"
-
-        if not chapter_file.exists():
+        chapter_file = find_chapter_file(self.project_root, chapter_num)
+        if chapter_file is None or not chapter_file.exists():
             return  # 文件不存在,跳过
 
         # 计算当前文件 Hash
@@ -363,18 +449,40 @@ class StructuredIndex:
 
         current_chapter = state.get('progress', {}).get('current_chapter', 0)
 
-        # 同步活跃伏笔(未回收)
-        active_plots = state.get('plot_threads', {}).get('active', [])
-        for plot in active_plots:
-            self._index_foreshadowing(plot, current_chapter, status="未回收")
+        plot_threads = state.get('plot_threads', {}) or {}
+
+        # 兼容新格式:plot_threads.foreshadowing = [{"content": "...", "status": "active", ...}, ...]
+        foreshadowing_items = plot_threads.get('foreshadowing', []) or []
+        active_count = 0
+        resolved_count = 0
 
-        # 同步已回收伏笔
-        resolved_plots = state.get('plot_threads', {}).get('resolved', [])
-        for plot in resolved_plots:
-            self._index_foreshadowing(plot, current_chapter, status="已回收")
+        for item in foreshadowing_items:
+            desc = item.get('description') or item.get('content') or ''
+            if not desc:
+                continue
+
+            raw_status = (item.get('status') or '').strip()
+            if raw_status in ['已回收', 'resolved']:
+                status = '已回收'
+                resolved_count += 1
+            else:
+                # 默认都视为未回收(兼容 active/未回收/pending/空)
+                status = '未回收'
+                active_count += 1
+
+            normalized = {
+                'description': desc,
+                'location': item.get('location', ''),
+                'characters': item.get('characters', []),
+                # 如果没有明确记录,至少给一个可用的默认值(避免紧急度恒为0)
+                'introduced_chapter': item.get('introduced_chapter') or item.get('planted_chapter') or 1,
+                'resolved_chapter': item.get('resolved_chapter', None),
+            }
+
+            self._index_foreshadowing(normalized, current_chapter, status=status)
 
         self.conn.commit()
-        print(f"✅ 伏笔索引已同步:{len(active_plots)} 条活跃 + {len(resolved_plots)} 条已回收")
+        print(f"✅ 伏笔索引已同步:{active_count} 条活跃 + {resolved_count} 条已回收")
 
     def _index_foreshadowing(self, plot: Dict, current_chapter: int, status: str):
         """为单个伏笔建立索引"""
@@ -391,8 +499,8 @@ class StructuredIndex:
             (id, content, location, characters, introduced_chapter, resolved_chapter, status, urgency, updated_at)
             VALUES ((SELECT id FROM foreshadowing_index WHERE content = ?), ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
         """, (
-            plot['description'],  # 用于查重
-            plot['description'],
+            plot.get('description', ''),  # 用于查重
+            plot.get('description', ''),
             location,
             json.dumps(characters, ensure_ascii=False),
             plot.get('introduced_chapter', 0),
@@ -444,6 +552,32 @@ class StructuredIndex:
 
     def _index_character(self, char: Dict, status: str = 'active'):
         """为单个角色建立索引"""
+        description = char.get('description') or char.get('desc') or ''
+        tier = str(char.get('tier', '') or '').strip()
+        importance = char.get('importance') or ('major' if tier == '核心' else 'minor')
+
+        first_appearance = char.get('first_appearance_chapter', 0) or 0
+        try:
+            first_appearance = int(first_appearance)
+        except (TypeError, ValueError):
+            first_appearance = 0
+
+        if first_appearance == 0:
+            src = char.get('first_appearance')
+            if isinstance(src, str):
+                m = re.search(r'第(\d+)章', src)
+                if m:
+                    try:
+                        first_appearance = int(m.group(1))
+                    except ValueError:
+                        first_appearance = 0
+
+        last_appearance = char.get('last_appearance_chapter', 0) or first_appearance
+        try:
+            last_appearance = int(last_appearance)
+        except (TypeError, ValueError):
+            last_appearance = first_appearance
+
         self.conn.execute("""
             INSERT OR REPLACE INTO characters
             (name, description, personality, importance, power_level,
@@ -451,12 +585,12 @@ class StructuredIndex:
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
         """, (
             char.get('name', ''),
-            char.get('description', ''),
+            description,
             char.get('personality', ''),
-            char.get('importance', 'minor'),
+            importance,
             char.get('power_level', ''),
-            char.get('first_appearance_chapter', 0),
-            char.get('last_appearance_chapter', 0),
+            first_appearance,
+            last_appearance,
             status
         ))
 
@@ -581,10 +715,11 @@ class StructuredIndex:
             return
 
         # 获取所有章节文件
-        chapter_files = sorted(self.chapters_dir.glob("第*.md"))
+        chapter_files = sorted(self.chapters_dir.rglob("第*.md"))
 
         print(f"🔍 发现 {len(chapter_files)} 个章节文件,开始重建索引...")
 
+        seen = set()
         for chapter_file in chapter_files:
             # 提取章节编号
             match = re.search(r'第(\d+)章', chapter_file.name)
@@ -592,14 +727,18 @@ class StructuredIndex:
                 continue
 
             chapter_num = int(match.group(1))
+            if chapter_num in seen:
+                continue
+            seen.add(chapter_num)
 
             # 重建索引
             self._rebuild_chapter_index(chapter_num, chapter_file)
 
         # 同步伏笔索引
         self.sync_foreshadowing_from_state()
+        self.sync_characters_from_state()
 
-        print(f"✅ 批量重建完成:{len(chapter_files)} 章")
+        print(f"✅ 批量重建完成:{len(seen)} 章")
 
     # ================== 查询与统计 ==================
 
@@ -693,6 +832,8 @@ def main():
 
                 # 同步伏笔索引
                 index.sync_foreshadowing_from_state()
+                index.bump_character_last_appearance_in_state(args.update_chapter, metadata.get("characters", []))
+                index.sync_characters_from_state()
 
             except json.JSONDecodeError as e:
                 print(f"❌ JSON 解析失败: {e}")
@@ -716,6 +857,8 @@ def main():
 
                 # 同步伏笔索引
                 index.sync_foreshadowing_from_state()
+                index.bump_character_last_appearance_in_state(args.update_chapter, metadata.get("characters", []))
+                index.sync_characters_from_state()
 
             except json.JSONDecodeError as e:
                 print(f"❌ JSON 解析失败: {e}")
@@ -740,6 +883,8 @@ def main():
 
             # 同步伏笔索引
             index.sync_foreshadowing_from_state()
+            index.bump_character_last_appearance_in_state(args.update_chapter, metadata.get("characters", []))
+            index.sync_characters_from_state()
 
         else:
             print("❌ 缺少参数:--metadata-file (推荐) / --metadata-json / --metadata")

+ 50 - 11
.claude/skills/webnovel-writer/scripts/update_state.py

@@ -14,7 +14,7 @@
   python update_state.py --protagonist-power "金丹" 3 "雷劫"
 
   # 更新人际关系
-  python update_state.py --relationship "李雪" affection 95 --relationship-status "李雪" "确认关系"
+  python update_state.py --relationship "李雪" affection 95
 
   # 记录伏笔
   python update_state.py --add-foreshadowing "神秘玉佩的秘密" "未回收"
@@ -56,6 +56,7 @@ from typing import Dict, Any, Optional
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # ============================================================================
 from security_utils import create_secure_directory
+from project_locator import resolve_state_file
 
 # Windows 编码兼容性修复
 if sys.platform == 'win32':
@@ -207,11 +208,16 @@ class StateUpdater:
 
     def update_golden_finger(self, name: str, level: int, cooldown: int):
         """更新金手指状态"""
-        self.state["protagonist_state"]["golden_finger"] = {
-            "name": name,
-            "level": level,
-            "cooldown": cooldown
-        }
+        ps = self.state.setdefault("protagonist_state", {})
+        golden_finger = ps.get("golden_finger")
+        if not isinstance(golden_finger, dict):
+            golden_finger = {}
+            ps["golden_finger"] = golden_finger
+
+        golden_finger.setdefault("skills", [])
+        golden_finger["name"] = name
+        golden_finger["level"] = level
+        golden_finger["cooldown"] = cooldown
         print(f"📝 更新金手指: {name} Lv.{level}, 冷却: {cooldown}天")
 
     def update_relationship(self, char_name: str, key: str, value: Any):
@@ -233,10 +239,34 @@ class StateUpdater:
                 print(f"⚠️  伏笔已存在: {content}")
                 return
 
+        # 归一化状态,避免 "待回收/进行中/active/pending" 等混用导致下游过滤漏掉
+        raw_status = "" if status is None else str(status).strip()
+        raw_status_lower = raw_status.lower()
+        if raw_status in {"已回收", "已完成", "已解决", "完成"} or raw_status_lower in {"resolved", "done", "complete"}:
+            status = "已回收"
+        elif (
+            raw_status in {"未回收", "待回收", "进行中", "未解决"}
+            or raw_status_lower in {"active", "pending"}
+            or not raw_status
+        ):
+            status = "未回收"
+        else:
+            status = "未回收"
+
+        planted_chapter = int(self.state.get("progress", {}).get("current_chapter", 0) or 0)
+        if planted_chapter <= 0:
+            planted_chapter = 1
+            print("? 未找到有效 progress.current_chapter,默认 planted_chapter=1")
+
+        target_chapter = planted_chapter + 100
+
         self.state["plot_threads"]["foreshadowing"].append({
             "content": content,
             "status": status,
-            "added_at": datetime.now().strftime("%Y-%m-%d")
+            "added_at": datetime.now().strftime("%Y-%m-%d"),
+            "planted_chapter": planted_chapter,
+            "target_chapter": target_chapter,
+            "tier": "支线"
         })
         print(f"📝 添加伏笔: {content}({status})")
 
@@ -376,10 +406,16 @@ def main():
         """
     )
 
+    parser.add_argument(
+        '--project-root',
+        default=None,
+        help='项目根目录(包含 .webnovel/state.json)。不提供时自动搜索(支持 webnovel-project/ 与父目录)。'
+    )
+
     parser.add_argument(
         '--state-file',
-        default='.webnovel/state.json',
-        help='state.json 文件路径(默认: .webnovel/state.json)'
+        default=None,
+        help='state.json 文件路径(可选)。不提供时从项目根目录自动定位为 .webnovel/state.json。'
     )
 
     parser.add_argument(
@@ -491,8 +527,11 @@ def main():
         parser.print_help()
         sys.exit(1)
 
+    # 解析 state.json 路径(支持从仓库根目录运行)
+    state_file_path = resolve_state_file(args.state_file, explicit_project_root=args.project_root)
+
     # 创建更新器
-    updater = StateUpdater(args.state_file, args.dry_run)
+    updater = StateUpdater(str(state_file_path), args.dry_run)
 
     # 加载状态文件
     if not updater.load():
@@ -564,7 +603,7 @@ def main():
         if not args.dry_run:
             print(f"\n💡 提示:")
             print(f"  - 原文件已备份: {updater.backup_file}")
-            print(f"  - 如需回滚,可复制备份文件到 {args.state_file}")
+            print(f"  - 如需回滚,可复制备份文件到 {updater.state_file}")
 
     except Exception as e:
         print(f"\n❌ 更新失败: {e}")

+ 37 - 25
.claude/skills/webnovel-writer/scripts/workflow_manager.py

@@ -17,6 +17,8 @@ from pathlib import Path
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # ============================================================================
 from security_utils import create_secure_directory
+from project_locator import resolve_project_root
+from chapter_paths import default_chapter_draft_path, find_chapter_file
 
 # UTF-8 编码修复(Windows兼容)
 if sys.platform == 'win32':
@@ -24,22 +26,9 @@ if sys.platform == 'win32':
     sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
     sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
 
-WORKFLOW_STATE_FILE = '.webnovel/workflow_state.json'
-
 def find_project_root():
-    """动态查找包含 .webnovel/ 的项目根目录"""
-    # 优先检查环境变量
-    if os.environ.get('WEBNOVEL_PROJECT_ROOT'):
-        return Path(os.environ['WEBNOVEL_PROJECT_ROOT'])
-
-    # 从当前目录往上查找
-    current = Path.cwd()
-    for parent in [current] + list(current.parents):
-        if (parent / '.webnovel').exists():
-            return parent
-
-    # 默认返回当前目录(向后兼容)
-    return current
+    """解析项目根目录(包含 .webnovel/state.json)"""
+    return resolve_project_root()
 
 def get_workflow_state_path():
     """获取 workflow_state.json 的完整路径"""
@@ -211,7 +200,17 @@ def analyze_recovery_options(interrupt_info):
     elif step_id == 'Step 2':
         # Step 2中断:可能有半成品文件
         chapter_file = interrupt_info['artifacts'].get('chapter_file', {})
-        chapter_path = f"正文/第{chapter_num:04d}章.md"
+
+        # 使用 chapter_paths 模块定位章节文件(兼容新旧目录结构)
+        project_root = find_project_root()
+        existing_chapter = find_chapter_file(project_root, chapter_num)
+        draft_path = None
+        if existing_chapter:
+            chapter_path = str(existing_chapter.relative_to(project_root))
+        else:
+            # 如果不存在,使用新格式的默认路径
+            draft_path = default_chapter_draft_path(project_root, chapter_num)
+            chapter_path = str(draft_path.relative_to(project_root))
 
         options = [{
             'option': 'A',
@@ -227,7 +226,8 @@ def analyze_recovery_options(interrupt_info):
         }]
 
         # 检查文件是否存在
-        if os.path.exists(chapter_path):
+        candidate = existing_chapter or draft_path
+        if candidate and candidate.exists():
             options.append({
                 'option': 'B',
                 'label': '回滚到上一章',
@@ -354,17 +354,29 @@ def cleanup_artifacts(chapter_num):
     """清理半成品artifacts"""
     artifacts_cleaned = []
 
-    # 删除章节文件
-    chapter_file = f"正文/第{chapter_num:04d}章.md"
-    if os.path.exists(chapter_file):
-        os.remove(chapter_file)
-        artifacts_cleaned.append(chapter_file)
+    project_root = find_project_root()
+
+    # 删除章节文件(兼容多种命名/目录结构)
+    chapter_path = find_chapter_file(project_root, chapter_num)
+    if chapter_path is None:
+        # 可能是“草稿路径”但尚未重命名
+        draft_path = default_chapter_draft_path(project_root, chapter_num)
+        if draft_path.exists():
+            chapter_path = draft_path
+
+    if chapter_path and chapter_path.exists():
+        chapter_path.unlink()
+        artifacts_cleaned.append(str(chapter_path.relative_to(project_root)))
 
     # 清理Git暂存区
-    result = subprocess.run(['git', 'reset', 'HEAD', '.'],
-                          capture_output=True, text=True)
+    result = subprocess.run(
+        ['git', 'reset', 'HEAD', '.'],
+        cwd=project_root,
+        capture_output=True,
+        text=True
+    )
     if result.returncode == 0:
-        artifacts_cleaned.append("Git暂存区已清理")
+        artifacts_cleaned.append("Git暂存区已清理(project)")
 
     return artifacts_cleaned
 

+ 5 - 1
.claude/skills/webnovel-writer/templates/genres/系统流.md

@@ -8,6 +8,9 @@
 
 ### 可视化面板
 - **原则**: 简单直接。不要列出几十项无用的数据(如:魅力、幸运),只列核心战斗属性。
+- **例外规则**: 如果你决定保留“魅力/幸运/精神”等非战斗属性,必须满足两个条件:
+  1) **短期内有反馈**:在接下来 5-10 章内至少触发 1 次可感知的剧情反馈(谈判/社交/运气/洞察),让读者觉得“这个数值有用”。  
+  2) **不刷屏**:面板展示要克制,避免每章重复完整属性表(只在升级/关键节点展示变化)。
 - **动态反馈**: 每次锻炼/战斗后,数据必须涨。(如:挥剑 1000 次,经验+10)。
 
 ### 任务生成逻辑
@@ -34,6 +37,7 @@
 
 ### 阶段一:工具期 (1-200 章)
 - 系统冷冰冰,机械发布任务。主角依赖系统生存。
+- **命名建议**: 系统名称/功能暂时未知时,正文用“代号/称呼”(如“继承者系统”“面板”),不要用“???”作为读者可见文本。
 
 ### 阶段二:伙伴期 (201-500 章)
 - 系统解锁智能语音/精灵形象。开始吐槽、卖萌、提供建议。
@@ -86,4 +90,4 @@
 [NEW_SYSTEM_FUNC: 功能名, 解锁条件, 作用]
 [NEW_SYSTEM_ITEM: 物品名, 价格, 效果]
 [NEW_SYSTEM_TASK: 任务名, 难度, 奖励, 惩罚]
-```
+```

+ 18 - 1
.claude/skills/webnovel-writer/templates/genres/都市异能.md

@@ -10,6 +10,7 @@
 - **现状**: 异能者极少,且被官方严格管控。普通人不知道异能存在。
 - **冲突**: 主角觉醒,试图隐藏身份,躲避官方调查和反派追杀。
 - **氛围**: 悬疑、压抑、都市传说。
+- **硬规则**: 只要出现“普通人可能注意到的大动静”(爆炸/坍塌/大火/多人受伤/封路),就必须给出“现实余波 + 遮蔽机制”的解释片段(本章或下一章完成)。
 
 ### 爆发期 (The Outbreak Era)
 - **现状**: 灵气浓度上升,动植物变异,觉醒者井喷。纸包不住火了。
@@ -69,10 +70,26 @@
 
 ---
 
+## 5. 现实余波与遮蔽机制(隐秘期专用)
+
+> **目的**:提升“世界质感”,避免读者吐槽“这么大爆炸没人管?”。
+
+### 必须交付的 1 个“余波细节”
+从下面任选 1-2 个落地到剧情里(给出具体措辞/时间/人物反应,而不是一句带过):
+- **现场处理**:警戒线、消防/救护车、封路、临时断电/封控
+- **痕迹抹除**:监控调取、手机被要求删除视频、路人被带走做笔录
+- **舆情版本**:微信群/短视频平台的碎片信息 + 官方通报的“合理解释”(施工事故/煤气爆炸/高压电起火)
+- **异能世界的规矩**:特管部门/守夜人出面,“普通人知情”如何被处理
+
+### 写法建议(不拖节奏)
+- 用 **1 个普通人视角碎片**(司机/保安/物业/群聊)+ **1 句官方口径**,就够把“隐秘期”钉牢。
+
+---
+
 ## 🛠️ NEW_ENTITY 标签扩展
 
 ```markdown
 [NEW_ABILITY: 异能名, 等级, 类型, 效果]
 [NEW_ORGANIZATION: 组织名, 性质, 负责人, 危险度]
 [NEW_MONSTER: 变异兽名, 等级, 出没地, 弱点]
-```
+```