浏览代码

feat: structured foreshadowing tracking + volume-aware workflow

lingfengQAQ 5 月之前
父节点
当前提交
b255a67a03
共有 23 个文件被更改,包括 1494 次插入672 次删除
  1. 24 0
      .claude/commands/webnovel-init.md
  2. 28 4
      .claude/commands/webnovel-plan.md
  3. 65 19
      .claude/commands/webnovel-write.md
  4. 41 0
      .claude/skills/webnovel-writer/SKILL.md
  5. 236 28
      .claude/skills/webnovel-writer/references/polish-guide.md
  6. 0 101
      .claude/skills/webnovel-writer/scripts/SECURITY_FIX_P0_extract_entities.patch
  7. 0 74
      .claude/skills/webnovel-writer/scripts/SECURITY_FIX_P1_backup_manager.patch
  8. 0 193
      .claude/skills/webnovel-writer/scripts/SECURITY_FIX_P1_file_permissions.patch
  9. 二进制
      .claude/skills/webnovel-writer/scripts/__pycache__/context_manager.cpython-313.pyc
  10. 二进制
      .claude/skills/webnovel-writer/scripts/__pycache__/structured_index.cpython-313.pyc
  11. 10 2
      .claude/skills/webnovel-writer/scripts/archive_manager.py
  12. 11 1
      .claude/skills/webnovel-writer/scripts/backup_manager.py
  13. 74 0
      .claude/skills/webnovel-writer/scripts/chapter_paths.py
  14. 42 14
      .claude/skills/webnovel-writer/scripts/context_manager.py
  15. 242 45
      .claude/skills/webnovel-writer/scripts/extract_entities.py
  16. 421 129
      .claude/skills/webnovel-writer/scripts/init_project.py
  17. 94 0
      .claude/skills/webnovel-writer/scripts/project_locator.py
  18. 98 16
      .claude/skills/webnovel-writer/scripts/status_reporter.py
  19. 47 17
      .claude/skills/webnovel-writer/scripts/structured_index.py
  20. 14 4
      .claude/skills/webnovel-writer/scripts/update_state.py
  21. 24 23
      .claude/skills/webnovel-writer/scripts/workflow_manager.py
  22. 5 1
      .claude/skills/webnovel-writer/templates/genres/系统流.md
  23. 18 1
      .claude/skills/webnovel-writer/templates/genres/都市异能.md

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

@@ -183,6 +183,29 @@ fi
 ```json
 ```json
 {
 {
   "questions": [
   "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": "系统性格",
       "header": "系统性格",
       "question": "系统与宿主的关系是?",
       "question": "系统与宿主的关系是?",
@@ -393,6 +416,7 @@ python .claude/skills/webnovel-writer/scripts/init_project.py \
   --protagonist-name "{主角姓名}" \
   --protagonist-name "{主角姓名}" \
   --target-words {目标字数} \
   --target-words {目标字数} \
   --target-chapters {目标章节数} \
   --target-chapters {目标章节数} \
+  --golden-finger-name "{金手指称呼}" \
   --golden-finger-type "{金手指类型}" \
   --golden-finger-type "{金手指类型}" \
   --golden-finger-style "{金手指风格}" \
   --golden-finger-style "{金手指风格}" \
   --core-selling-points "{核心卖点1},{核心卖点2}"
   --core-selling-points "{核心卖点1},{核心卖点2}"

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

@@ -79,10 +79,10 @@ cat webnovel-project/大纲/总纲.md
       "header": "核心冲突",
       "header": "核心冲突",
       "question": "第 {volume_id} 卷的核心冲突是什么?",
       "question": "第 {volume_id} 卷的核心冲突是什么?",
       "options": [
       "options": [
-        {"label": "宗门竞争", "description": "宗门内部的明争暗斗"},
-        {"label": "外敌入侵", "description": "外部势力攻击"},
-        {"label": "秘境历练", "description": "在危险秘境中的冒险"},
-        {"label": "境界突破", "description": "专注个人成长和修炼突破"}
+        {"label": "生存危机", "description": "活下去/被追杀/高压困境下求生"},
+        {"label": "势力博弈", "description": "组织/宗门/公司/财团之间的明暗斗"},
+        {"label": "揭秘阴谋", "description": "围绕金手指/世界真相/黑幕的调查与反制"},
+        {"label": "成长突破", "description": "围绕升级与能力成长推进剧情"}
       ],
       ],
       "multiSelect": false
       "multiSelect": false
     },
     },
@@ -117,6 +117,30 @@ cat webnovel-project/大纲/总纲.md
         {"label": "淡化感情线", "description": "本卷专注主线,感情线为辅"}
         {"label": "淡化感情线", "description": "本卷专注主线,感情线为辅"}
       ],
       ],
       "multiSelect": false
       "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
     }
     }
   ]
   ]
 }
 }

+ 65 - 19
.claude/commands/webnovel-write.md

@@ -57,7 +57,7 @@ Search order:
 
 
 ```bash
 ```bash
 # Read state.json to get current progress
 # 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
 **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(层级: 核心/支线/装饰)
    - ✅ Introduce required Entities with `[NEW_ENTITY: 类型, 名称, 描述, 层级]` tags(层级: 核心/支线/装饰)
    - ✅ Track new golden finger skills with `[GOLDEN_FINGER_SKILL: 技能名, 等级, 描述, 冷却时间]`
    - ✅ Track new golden finger skills with `[GOLDEN_FINGER_SKILL: 技能名, 等级, 描述, 冷却时间]`
    - ✅ Plant Foreshadowing as planned
    - ✅ Plant Foreshadowing as planned
+   - ✅ **禁止自创“工作流标签”**:除 `[NEW_ENTITY]` / `[GOLDEN_FINGER_SKILL]` / `[OUTLINE_DEVIATION]` 外,不要在正文里新增任何方括号标签(例如 `[FORESHADOWING: ...]`、`[COOL_POINT: ...]` 等);如需伏笔结构化,仅允许使用 **`[FORESHADOWING_JSON: {...}]` 且必须包在 HTML 注释里**(不影响读者阅读)
+   - ✅ **禁用占位符正文**:正文里不要出现“???系统/???功能/???”;未知信息用“代号/称呼”或“权限屏蔽/无法读取”等叙述句替代
+   - ✅ **都市异能(隐秘期)余波要求**:若出现“会被普通人注意到的大动静”(爆炸/坍塌/火光/多人伤亡),本章或下一章必须交代一个现实层面的“余波/遮蔽机制”细节(警戒线、监控调取、热搜/群聊传闻、官方说法等)
    - ✅ Protagonist power ≤ state.json (no power inflation)
    - ✅ Protagonist power ≤ state.json (no power inflation)
    - ✅ Apply review feedback (avoid Critical Issues)
    - ✅ 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小时]
 > [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: 陆辰, 主角, ...              ❌ 缺少方括号
 > NEW_ENTITY: 陆辰, 主角, ...              ❌ 缺少方括号
 > [NEW_ENTITY: 陆辰 | 主角 | ...]          ❌ 竖线分隔符
 > [NEW_ENTITY: 陆辰 | 主角 | ...]          ❌ 竖线分隔符
 > ```
 > ```
 >
 >
-> **标签放置位置**: 在角色/地点/物品首次出现的段落末尾,或章节末尾统一放置
+> **标签放置位置**: 在角色/地点/物品首次出现的段落末尾,或章节末尾统一放置。为便于后处理,建议**标签单独成行**(不要把标签夹在一句正文里)
 
 
 ---
 ---
 
 
@@ -309,7 +319,8 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py start-step \
 
 
 5. **Save Output**:
 5. **Save Output**:
    ```
    ```
-   File: 正文/第{N:04d}章.md
+   File: 正文/第{volume_num}卷/第{N:03d}章-{标题}.md
+   (也可先保存为:正文/第{volume_num}卷/第{N:03d}章.md,后续再补标题重命名)
 
 
    Format:
    Format:
    # 第 {N} 章:{标题}
    # 第 {N} 章:{标题}
@@ -346,7 +357,7 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py start-step \
 ```bash
 ```bash
 python .claude/skills/webnovel-writer/scripts/workflow_manager.py complete-step \
 python .claude/skills/webnovel-writer/scripts/workflow_manager.py complete-step \
   --step-id "Step 2" \
   --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] 标签必须原样保留
 🔒 标签保护:[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痕迹检测与修正
 #### 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)
 - [ ] 主角实力未膨胀(≤ state.json)
 - [ ] [NEW_ENTITY] 和 [GOLDEN_FINGER_SKILL] 标签保留完整
 - [ ] [NEW_ENTITY] 和 [GOLDEN_FINGER_SKILL] 标签保留完整
-- [ ] AI痕迹已清除(无过度总结、完美结构)
+- [ ] AI痕迹量化达标(总结词=0,学术词<1次/1000字)
+- [ ] 自然化量化达标(停顿词≥2次/1000字,短句30-50%)
 - [ ] 风格与前文一致(语言/叙事/角色/场景)
 - [ ] 风格与前文一致(语言/叙事/角色/场景)
 - [ ] 字数仍在 3000-5000 范围内
 - [ ] 字数仍在 3000-5000 范围内
 
 
@@ -491,7 +528,7 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py start-step \
 
 
 **Save Polished Output**:
 **Save Polished Output**:
 
 
-更新章节文件 `正文/第{N:04d}章.md`,覆盖原草稿。
+更新章节文件 `正文/第{volume_num}卷/第{N:03d}章-{标题}.md`,覆盖原草稿。
 
 
 **After completing Step 2.5**, **YOU MUST run**:
 **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"
   --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
 ```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**:
 **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**:
 **Use Task tool to call metadata-extractor agent**:
 
 
 ```python
 ```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()
     chapter_content = f.read()
 
 
 # Call metadata-extractor agent
 # Call metadata-extractor agent
@@ -688,13 +734,13 @@ os.unlink(metadata_file)  # Delete temporary file
 
 
 1. **Direct JSON string** (Linux/macOS only):
 1. **Direct JSON string** (Linux/macOS only):
 ```bash
 ```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):
 2. **Fallback mode** (if agent unavailable):
 ```bash
 ```bash
 # Direct file-based extraction (legacy mode, 60% accuracy)
 # 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):
 **Query Examples** (for future use):
 ```bash
 ```bash
 # 查询地点相关章节(O(log n) vs O(n) 文件遍历)
 # 查询地点相关章节(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 章未回收)
 # 查询紧急伏笔(超过 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**:
 **IMPORTANT**:
@@ -1095,7 +1141,7 @@ python .claude/skills/webnovel-writer/scripts/workflow_manager.py complete-task
 - [ ] Archive check result confirmed (无需归档 OR 归档完成)
 - [ ] Archive check result confirmed (无需归档 OR 归档完成)
 
 
 **Chapter Content**:
 **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)
 - [ ] [NEW_ENTITY] and [GOLDEN_FINGER_SKILL] tags extracted (if any)
 
 
 **Content Polishing** (Step 2.5):
 **Content Polishing** (Step 2.5):

+ 41 - 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: ...]`),除非同步更新脚本与规范
+
+---
+
 ## 📖 爽点设计指南
 ## 📖 爽点设计指南
 
 
 ### 爽点类型与标准流程
 ### 爽点类型与标准流程

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

@@ -2,6 +2,57 @@
 
 
 > **用途**:为 `/webnovel-write` 命令的 Step 2.5 (Polish Content) 提供详细参考
 > **用途**:为 `/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. 快速参考卡片
 ## 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%的形容词
+对话书面 → 口语化+省略主语
+转折生硬 → 删除连接词,用句号
 ```
 ```

+ 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
-##
-## ============================================================================

二进制
.claude/skills/webnovel-writer/scripts/__pycache__/context_manager.cpython-313.pyc


二进制
.claude/skills/webnovel-writer/scripts/__pycache__/structured_index.cpython-313.pyc


+ 10 - 2
.claude/skills/webnovel-writer/scripts/archive_manager.py

@@ -43,6 +43,7 @@ from pathlib import Path
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # ============================================================================
 # ============================================================================
 from security_utils import create_secure_directory
 from security_utils import create_secure_directory
+from project_locator import resolve_project_root
 
 
 # Windows UTF-8 编码修复
 # Windows UTF-8 编码修复
 if sys.platform == 'win32':
 if sys.platform == 'win32':
@@ -460,8 +461,15 @@ def main():
 
 
     args = parser.parse_args()
     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:
     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)
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # ============================================================================
 # ============================================================================
 from security_utils import sanitize_commit_message
 from security_utils import sanitize_commit_message
+from project_locator import resolve_project_root
 
 
 # Windows 编码兼容性修复
 # Windows 编码兼容性修复
 if sys.platform == 'win32':
 if sys.platform == 'win32':
@@ -393,8 +394,17 @@ def main():
 
 
     args = parser.parse_args()
     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:
     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"
+

+ 42 - 14
.claude/skills/webnovel-writer/scripts/context_manager.py

@@ -50,6 +50,8 @@ import sys
 import re
 import re
 from pathlib import Path
 from pathlib import Path
 from typing import Dict, List, Any, Optional
 from typing import Dict, List, Any, Optional
+from project_locator import resolve_project_root
+from chapter_paths import find_chapter_file
 
 
 # Windows 编码兼容性修复
 # Windows 编码兼容性修复
 if sys.platform == 'win32':
 if sys.platform == 'win32':
@@ -206,22 +208,37 @@ class ContextManager:
             if i <= 0:
             if i <= 0:
                 continue
                 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
                 continue
 
 
             with open(chapter_file, 'r', encoding='utf-8') as f:
             with open(chapter_file, 'r', encoding='utf-8') as f:
                 content = f.read()
                 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}")
             summaries.append(f"第 {i} 章摘要:{summary}")
 
 
         return summaries
         return summaries
@@ -396,7 +413,7 @@ class ContextManager:
         relevant = []
         relevant = []
 
 
         for item in all_foreshadowing:
         for item in all_foreshadowing:
-            if item.get("status") != "未回收":
+            if item.get("status") not in ["未回收", "active", "pending", None, ""]:
                 continue
                 continue
 
 
             content = item.get("content", "")
             content = item.get("content", "")
@@ -486,7 +503,7 @@ class ContextManager:
         urgent = []
         urgent = []
 
 
         for item in all_foreshadowing:
         for item in all_foreshadowing:
-            if item.get("status") != "未回收":
+            if item.get("status") not in ["未回收", "active", "pending", None, ""]:
                 continue
                 continue
 
 
             # 计算已埋章节数(粗略:假设每章对应 1 个章节号增量)
             # 计算已埋章节数(粗略:假设每章对应 1 个章节号增量)
@@ -571,13 +588,21 @@ def main():
 
 
     args = parser.parse_args()
     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
     characters = None
     if args.characters:
     if args.characters:
         characters = [c.strip() for c in args.characters.split(',')]
         characters = [c.strip() for c in args.characters.split(',')]
 
 
     # 创建管理器
     # 创建管理器
-    manager = ContextManager(args.project_root)
+    manager = ContextManager(project_root)
 
 
     # 加载状态
     # 加载状态
     if not manager.load_state():
     if not manager.load_state():
@@ -594,7 +619,10 @@ def main():
         print("\n📄 上下文预览:")
         print("\n📄 上下文预览:")
         print(json.dumps(context, ensure_ascii=False, indent=2))
         print(json.dumps(context, ensure_ascii=False, indent=2))
     else:
     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__":
 if __name__ == "__main__":
     main()
     main()

+ 242 - 45
.claude/skills/webnovel-writer/scripts/extract_entities.py

@@ -24,14 +24,17 @@ import re
 import json
 import json
 import os
 import os
 import sys
 import sys
+import argparse
 from pathlib import Path
 from pathlib import Path
 from datetime import datetime
 from datetime import datetime
-from typing import List, Dict, Tuple
+from typing import List, Dict, Tuple, Optional, Any
 
 
 # ============================================================================
 # ============================================================================
 # 安全修复:导入安全工具函数(P0 CRITICAL)
 # 安全修复:导入安全工具函数(P0 CRITICAL)
 # ============================================================================
 # ============================================================================
 from security_utils import sanitize_filename, create_secure_directory
 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 编码兼容性修复
 # Windows 编码兼容性修复
 if sys.platform == 'win32':
 if sys.platform == 'win32':
@@ -180,6 +183,69 @@ def extract_golden_finger_skills(file_path: str) -> List[Dict]:
 
 
     return skills
     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:
 def categorize_character(desc: str) -> str:
     """
     """
     根据描述判断角色分类
     根据描述判断角色分类
@@ -320,8 +386,15 @@ def update_power_system(entity: Dict, target_file: str):
     with open(target_file, 'w', encoding='utf-8') as f:
     with open(target_file, 'w', encoding='utf-8') as f:
         f.write(content)
         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 中的实体记录(支持层级分类/金手指技能/伏笔结构化)"""
     with open(state_file, 'r', encoding='utf-8') as f:
     with open(state_file, 'r', encoding='utf-8') as f:
         state = json.load(f)
         state = json.load(f)
 
 
@@ -415,6 +488,79 @@ def update_state_json(entities: List[Dict], state_file: str, golden_finger_skill
                 })
                 })
                 print(f"  ✨ 新增金手指技能: {skill['name']} ({skill['level']})")
                 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": "active",
+                    "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')
     backup_file = state_file.replace('.json', f'.backup_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json')
     os.rename(state_file, backup_file)
     os.rename(state_file, backup_file)
@@ -539,82 +685,131 @@ def sync_entity_to_settings(entity: Dict, project_root: str, auto_mode: bool = F
         return False
         return False
 
 
 def main():
 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}")
     print(f"📖 正在扫描: {chapter_file}")
     entities = extract_new_entities(chapter_file)
     entities = extract_new_entities(chapter_file)
-
-    # 提取金手指技能
     golden_finger_skills = extract_golden_finger_skills(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
         return
 
 
     if entities:
     if entities:
         print(f"\n🔍 发现 {len(entities)} 个新实体:")
         print(f"\n🔍 发现 {len(entities)} 个新实体:")
         for i, entity in enumerate(entities, 1):
         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:
     if golden_finger_skills:
         print(f"\n✨ 发现 {len(golden_finger_skills)} 个金手指技能:")
         print(f"\n✨ 发现 {len(golden_finger_skills)} 个金手指技能:")
         for i, skill in enumerate(golden_finger_skills, 1):
         for i, skill in enumerate(golden_finger_skills, 1):
             print(f"  {i}. {skill['name']} ({skill['level']}) - {skill['desc'][:25]}...")
             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:
     if dry_run:
         print("\n⚠️  Dry-run 模式,不执行实际写入")
         print("\n⚠️  Dry-run 模式,不执行实际写入")
         return
         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)
         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:
     for entity in entities:
         if sync_entity_to_settings(entity, str(project_root), auto_mode):
         if sync_entity_to_settings(entity, str(project_root), auto_mode):
             success_count += 1
             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)} 个")
     print(f"  - 实体同步: {success_count}/{len(entities)} 个")
     if golden_finger_skills:
     if golden_finger_skills:
         print(f"  - 金手指技能: {len(golden_finger_skills)} 个")
         print(f"  - 金手指技能: {len(golden_finger_skills)} 个")
+    if foreshadowing_items:
+        print(f"  - 伏笔同步: {len(foreshadowing_items)} 条")
 
 
     if not auto_mode:
     if not auto_mode:
         print("\n💡 建议:")
         print("\n💡 建议:")
@@ -623,6 +818,8 @@ def main():
         print("  3. 确认 .webnovel/state.json 中的实体记录")
         print("  3. 确认 .webnovel/state.json 中的实体记录")
         if golden_finger_skills:
         if golden_finger_skills:
             print("  4. 检查金手指技能是否正确记录在 protagonist_state.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__":
 if __name__ == "__main__":
     main()
     main()

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

@@ -2,115 +2,381 @@
 # -*- coding: utf-8 -*-
 # -*- 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 json
 import subprocess
 import subprocess
-from pathlib import Path
+import sys
 from datetime import datetime
 from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict
+
 
 
 # Windows 编码兼容性修复
 # Windows 编码兼容性修复
-if sys.platform == 'win32':
+if sys.platform == "win32":
     import io
     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 = [
     directories = [
         ".webnovel/backups",
         ".webnovel/backups",
+        ".webnovel/archive",
         "设定集/角色库/主要角色",
         "设定集/角色库/主要角色",
         "设定集/角色库/次要角色",
         "设定集/角色库/次要角色",
         "设定集/角色库/反派角色",
         "设定集/角色库/反派角色",
+        "设定集/物品库",
+        "设定集/其他设定",
         "大纲",
         "大纲",
-        "正文",
-        "审查报告"
+        "正文/第1卷",
+        "审查报告",
     ]
     ]
-
     for dir_path in directories:
     for dir_path in directories:
         (project_path / dir_path).mkdir(parents=True, exist_ok=True)
         (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,
             "title": title,
             "genre": genre,
             "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_template = _read_text_if_exists(templates_dir / "genres" / f"{genre}.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__/
 __pycache__/
 *.py[cod]
 *.py[cod]
 *.so
 *.so
@@ -127,52 +393,78 @@ __pycache__/
 # Don't ignore .webnovel (we need to track state.json)
 # Don't ignore .webnovel (we need to track state.json)
 # But ignore cache files
 # But ignore cache files
 .webnovel/context_cache.json
 .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"
+

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

@@ -86,6 +86,8 @@ from pathlib import Path
 from typing import Dict, List, Any, Tuple
 from typing import Dict, List, Any, Tuple
 from datetime import datetime
 from datetime import datetime
 from collections import defaultdict
 from collections import defaultdict
+from project_locator import resolve_project_root
+from chapter_paths import extract_chapter_num_from_filename
 
 
 # Windows 编码兼容性修复
 # Windows 编码兼容性修复
 if sys.platform == 'win32':
 if sys.platform == 'win32':
@@ -104,6 +106,26 @@ class StatusReporter:
         self.state = None
         self.state = None
         self.chapters_data = []
         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:
     def load_state(self) -> bool:
         """加载 state.json"""
         """加载 state.json"""
         if not self.state_file.exists():
         if not self.state_file.exists():
@@ -121,14 +143,26 @@ class StatusReporter:
             print(f"⚠️  正文目录不存在: {self.chapters_dir}")
             print(f"⚠️  正文目录不存在: {self.chapters_dir}")
             return
             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
                 continue
 
 
-            chapter_num = int(match.group(1))
-
             # 读取章节内容
             # 读取章节内容
             with open(chapter_file, 'r', encoding='utf-8') as f:
             with open(chapter_file, 'r', encoding='utf-8') as f:
                 content = f.read()
                 content = f.read()
@@ -139,14 +173,51 @@ class StatusReporter:
             text = re.sub(r'---', '', text)  # 去除分隔线
             text = re.sub(r'---', '', text)  # 去除分隔线
             word_count = len(text.strip())
             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({
             self.chapters_data.append({
                 "chapter": chapter_num,
                 "chapter": chapter_num,
                 "file": chapter_file,
                 "file": chapter_file,
                 "word_count": word_count,
                 "word_count": word_count,
-                "characters": characters
+                "characters": characters,
+                "dominant": dominant_strand,
+                "cool_point": cool_point_type,
             })
             })
 
 
     def analyze_characters(self) -> Dict:
     def analyze_characters(self) -> Dict:
@@ -206,7 +277,8 @@ class StatusReporter:
         overdue = []
         overdue = []
 
 
         for item in foreshadowing:
         for item in foreshadowing:
-            if item.get("status") != "未回收":
+            status = item.get("status")
+            if status not in ["未回收", "active", "pending", None, ""]:
                 continue
                 continue
 
 
             # 假设每个伏笔记录了"added_chapter"(埋设章节)
             # 假设每个伏笔记录了"added_chapter"(埋设章节)
@@ -273,7 +345,7 @@ class StatusReporter:
         urgency_list = []
         urgency_list = []
 
 
         for item in foreshadowing:
         for item in foreshadowing:
-            if item.get("status") == "已回收":
+            if item.get("status") in ["已回收", "resolved"]:
                 continue
                 continue
 
 
             content = item.get("content", "")
             content = item.get("content", "")
@@ -350,7 +422,7 @@ class StatusReporter:
         total = len(history)
         total = len(history)
 
 
         for entry in history:
         for entry in history:
-            strand = entry.get("strand", "").lower()
+            strand = (entry.get("strand") or entry.get("dominant") or "").lower()
             if strand in ["quest", "主线", "战斗", "任务"]:
             if strand in ["quest", "主线", "战斗", "任务"]:
                 quest_count += 1
                 quest_count += 1
             elif strand in ["fire", "感情", "感情线", "互动"]:
             elif strand in ["fire", "感情", "感情线", "互动"]:
@@ -370,7 +442,7 @@ class StatusReporter:
         quest_streak = 0
         quest_streak = 0
         max_quest_streak = 0
         max_quest_streak = 0
         for entry in history:
         for entry in history:
-            strand = entry.get("strand", "").lower()
+            strand = (entry.get("strand") or entry.get("dominant") or "").lower()
             if strand in ["quest", "主线", "战斗", "任务"]:
             if strand in ["quest", "主线", "战斗", "任务"]:
                 quest_streak += 1
                 quest_streak += 1
                 max_quest_streak = max(max_quest_streak, quest_streak)
                 max_quest_streak = max(max_quest_streak, quest_streak)
@@ -384,7 +456,7 @@ class StatusReporter:
         fire_gap = 0
         fire_gap = 0
         max_fire_gap = 0
         max_fire_gap = 0
         for entry in history:
         for entry in history:
-            strand = entry.get("strand", "").lower()
+            strand = (entry.get("strand") or entry.get("dominant") or "").lower()
             if strand in ["fire", "感情", "感情线", "互动"]:
             if strand in ["fire", "感情", "感情线", "互动"]:
                 max_fire_gap = max(max_fire_gap, fire_gap)
                 max_fire_gap = max(max_fire_gap, fire_gap)
                 fire_gap = 0
                 fire_gap = 0
@@ -399,7 +471,7 @@ class StatusReporter:
         const_gap = 0
         const_gap = 0
         max_const_gap = 0
         max_const_gap = 0
         for entry in history:
         for entry in history:
-            strand = entry.get("strand", "").lower()
+            strand = (entry.get("strand") or entry.get("dominant") or "").lower()
             if strand in ["constellation", "世界观", "背景", "势力"]:
             if strand in ["constellation", "世界观", "背景", "势力"]:
                 max_const_gap = max(max_const_gap, const_gap)
                 max_const_gap = max(max_const_gap, const_gap)
                 const_gap = 0
                 const_gap = 0
@@ -829,8 +901,16 @@ def main():
 
 
     args = parser.parse_args()
     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():
     if not reporter.load_state():
@@ -848,6 +928,8 @@ def main():
 
 
     # 保存报告
     # 保存报告
     output_file = Path(args.output)
     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)
     output_file.parent.mkdir(parents=True, exist_ok=True)
 
 
     with open(output_file, 'w', encoding='utf-8') as f:
     with open(output_file, 'w', encoding='utf-8') as f:

+ 47 - 17
.claude/skills/webnovel-writer/scripts/structured_index.py

@@ -49,6 +49,8 @@ from typing import Optional, List, Dict, Tuple
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # ============================================================================
 # ============================================================================
 from security_utils import create_secure_directory
 from security_utils import create_secure_directory
+from project_locator import resolve_project_root
+from chapter_paths import find_chapter_file
 
 
 
 
 class StructuredIndex:
 class StructuredIndex:
@@ -56,7 +58,10 @@ class StructuredIndex:
 
 
     def __init__(self, project_root=None):
     def __init__(self, project_root=None):
         if project_root is None:
         if project_root is None:
-            project_root = Path.cwd()
+            try:
+                project_root = resolve_project_root()
+            except FileNotFoundError:
+                project_root = Path.cwd()
         else:
         else:
             project_root = Path(project_root)
             project_root = Path(project_root)
 
 
@@ -259,9 +264,8 @@ class StructuredIndex:
         - 增加耗时:~5ms(Hash 计算 + 对比)
         - 增加耗时:~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  # 文件不存在,跳过
             return  # 文件不存在,跳过
 
 
         # 计算当前文件 Hash
         # 计算当前文件 Hash
@@ -363,18 +367,40 @@ class StructuredIndex:
 
 
         current_chapter = state.get('progress', {}).get('current_chapter', 0)
         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 {}
 
 
-        # 同步已回收伏笔
-        resolved_plots = state.get('plot_threads', {}).get('resolved', [])
-        for plot in resolved_plots:
-            self._index_foreshadowing(plot, current_chapter, status="已回收")
+        # 兼容新格式:plot_threads.foreshadowing = [{"content": "...", "status": "active", ...}, ...]
+        foreshadowing_items = plot_threads.get('foreshadowing', []) or []
+        active_count = 0
+        resolved_count = 0
+
+        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()
         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):
     def _index_foreshadowing(self, plot: Dict, current_chapter: int, status: str):
         """为单个伏笔建立索引"""
         """为单个伏笔建立索引"""
@@ -391,8 +417,8 @@ class StructuredIndex:
             (id, content, location, characters, introduced_chapter, resolved_chapter, status, urgency, updated_at)
             (id, content, location, characters, introduced_chapter, resolved_chapter, status, urgency, updated_at)
             VALUES ((SELECT id FROM foreshadowing_index WHERE content = ?), ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
             VALUES ((SELECT id FROM foreshadowing_index WHERE content = ?), ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
         """, (
         """, (
-            plot['description'],  # 用于查重
-            plot['description'],
+            plot.get('description', ''),  # 用于查重
+            plot.get('description', ''),
             location,
             location,
             json.dumps(characters, ensure_ascii=False),
             json.dumps(characters, ensure_ascii=False),
             plot.get('introduced_chapter', 0),
             plot.get('introduced_chapter', 0),
@@ -581,10 +607,11 @@ class StructuredIndex:
             return
             return
 
 
         # 获取所有章节文件
         # 获取所有章节文件
-        chapter_files = sorted(self.chapters_dir.glob("第*.md"))
+        chapter_files = sorted(self.chapters_dir.rglob("第*.md"))
 
 
         print(f"🔍 发现 {len(chapter_files)} 个章节文件,开始重建索引...")
         print(f"🔍 发现 {len(chapter_files)} 个章节文件,开始重建索引...")
 
 
+        seen = set()
         for chapter_file in chapter_files:
         for chapter_file in chapter_files:
             # 提取章节编号
             # 提取章节编号
             match = re.search(r'第(\d+)章', chapter_file.name)
             match = re.search(r'第(\d+)章', chapter_file.name)
@@ -592,6 +619,9 @@ class StructuredIndex:
                 continue
                 continue
 
 
             chapter_num = int(match.group(1))
             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._rebuild_chapter_index(chapter_num, chapter_file)
@@ -599,7 +629,7 @@ class StructuredIndex:
         # 同步伏笔索引
         # 同步伏笔索引
         self.sync_foreshadowing_from_state()
         self.sync_foreshadowing_from_state()
 
 
-        print(f"✅ 批量重建完成:{len(chapter_files)} 章")
+        print(f"✅ 批量重建完成:{len(seen)} 章")
 
 
     # ================== 查询与统计 ==================
     # ================== 查询与统计 ==================
 
 

+ 14 - 4
.claude/skills/webnovel-writer/scripts/update_state.py

@@ -56,6 +56,7 @@ from typing import Dict, Any, Optional
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # ============================================================================
 # ============================================================================
 from security_utils import create_secure_directory
 from security_utils import create_secure_directory
+from project_locator import resolve_state_file
 
 
 # Windows 编码兼容性修复
 # Windows 编码兼容性修复
 if sys.platform == 'win32':
 if sys.platform == 'win32':
@@ -376,10 +377,16 @@ def main():
         """
         """
     )
     )
 
 
+    parser.add_argument(
+        '--project-root',
+        default=None,
+        help='项目根目录(包含 .webnovel/state.json)。不提供时自动搜索(支持 webnovel-project/ 与父目录)。'
+    )
+
     parser.add_argument(
     parser.add_argument(
         '--state-file',
         '--state-file',
-        default='.webnovel/state.json',
-        help='state.json 文件路径(默认: .webnovel/state.json)'
+        default=None,
+        help='state.json 文件路径(可选)。不提供时从项目根目录自动定位为 .webnovel/state.json。'
     )
     )
 
 
     parser.add_argument(
     parser.add_argument(
@@ -491,8 +498,11 @@ def main():
         parser.print_help()
         parser.print_help()
         sys.exit(1)
         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():
     if not updater.load():
@@ -564,7 +574,7 @@ def main():
         if not args.dry_run:
         if not args.dry_run:
             print(f"\n💡 提示:")
             print(f"\n💡 提示:")
             print(f"  - 原文件已备份: {updater.backup_file}")
             print(f"  - 原文件已备份: {updater.backup_file}")
-            print(f"  - 如需回滚,可复制备份文件到 {args.state_file}")
+            print(f"  - 如需回滚,可复制备份文件到 {updater.state_file}")
 
 
     except Exception as e:
     except Exception as e:
         print(f"\n❌ 更新失败: {e}")
         print(f"\n❌ 更新失败: {e}")

+ 24 - 23
.claude/skills/webnovel-writer/scripts/workflow_manager.py

@@ -17,6 +17,8 @@ from pathlib import Path
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # ============================================================================
 # ============================================================================
 from security_utils import create_secure_directory
 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兼容)
 # UTF-8 编码修复(Windows兼容)
 if sys.platform == 'win32':
 if sys.platform == 'win32':
@@ -24,22 +26,9 @@ if sys.platform == 'win32':
     sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
     sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
     sys.stderr = io.TextIOWrapper(sys.stderr.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():
 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():
 def get_workflow_state_path():
     """获取 workflow_state.json 的完整路径"""
     """获取 workflow_state.json 的完整路径"""
@@ -354,17 +343,29 @@ def cleanup_artifacts(chapter_num):
     """清理半成品artifacts"""
     """清理半成品artifacts"""
     artifacts_cleaned = []
     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暂存区
     # 清理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:
     if result.returncode == 0:
-        artifacts_cleaned.append("Git暂存区已清理")
+        artifacts_cleaned.append("Git暂存区已清理(project)")
 
 
     return artifacts_cleaned
     return artifacts_cleaned
 
 

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

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

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

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