Kaynağa Gözat

feat: structured foreshadowing tracking + volume-aware workflow

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

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

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

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

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

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

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

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

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

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

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

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

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


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


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

@@ -43,6 +43,7 @@ from pathlib import Path
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # ============================================================================
 from security_utils import create_secure_directory
+from project_locator import resolve_project_root
 
 # Windows UTF-8 编码修复
 if sys.platform == 'win32':
@@ -460,8 +461,15 @@ def main():
 
     args = parser.parse_args()
 
-    # 创建管理器
-    manager = ArchiveManager(project_root=args.project_root)
+    # 创建管理器(支持从仓库根目录运行)
+    project_root = args.project_root
+    if project_root is None and not (Path.cwd() / ".webnovel" / "state.json").exists():
+        try:
+            project_root = str(resolve_project_root())
+        except FileNotFoundError:
+            project_root = None
+
+    manager = ArchiveManager(project_root=project_root)
 
     # 执行操作
     if args.auto_check or args.force:

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

@@ -59,6 +59,7 @@ from typing import Optional, List, Tuple
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # ============================================================================
 from security_utils import sanitize_commit_message
+from project_locator import resolve_project_root
 
 # Windows 编码兼容性修复
 if sys.platform == 'win32':
@@ -393,8 +394,17 @@ def main():
 
     args = parser.parse_args()
 
+    # 解析项目根目录(支持从仓库根目录运行)
+    project_root = args.project_root
+    if project_root == '.' and not (Path('.') / '.webnovel' / 'state.json').exists():
+        try:
+            project_root = str(resolve_project_root())
+        except FileNotFoundError:
+            # 维持向后兼容:仍然使用用户提供的 cwd
+            project_root = args.project_root
+
     # 创建管理器
-    manager = GitBackupManager(args.project_root)
+    manager = GitBackupManager(project_root)
 
     # 执行操作
     if args.chapter:

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

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

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

@@ -50,6 +50,8 @@ import sys
 import re
 from pathlib import Path
 from typing import Dict, List, Any, Optional
+from project_locator import resolve_project_root
+from chapter_paths import find_chapter_file
 
 # Windows 编码兼容性修复
 if sys.platform == 'win32':
@@ -206,22 +208,37 @@ class ContextManager:
             if i <= 0:
                 continue
 
-            chapter_file = self.chapters_dir / f"第{i:04d}章.md"
-            if not chapter_file.exists():
+            chapter_file = find_chapter_file(self.project_root, i)
+            if not chapter_file:
                 continue
 
             with open(chapter_file, 'r', encoding='utf-8') as f:
                 content = f.read()
 
-            # 提取正文(去除标题、元数据等)
-            text_match = re.search(r'---\n\n(.+)', content, re.DOTALL)
-            if text_match:
-                text = text_match.group(1).strip()
-            else:
-                text = content
+            # 提取正文摘要(避开标签/统计区块)
+            lines = content.splitlines()
+            # 去掉标题行
+            if lines and lines[0].lstrip().startswith("#"):
+                lines = lines[1:]
+
+            buf: List[str] = []
+            for line in lines:
+                s = line.strip()
+                if not s:
+                    continue
+                if s.startswith("## 本章统计"):
+                    break
+                if s == "---":
+                    continue
+                # 过滤工作流标签行([NEW_ENTITY] 等)
+                if s.startswith("[") and s.endswith("]"):
+                    continue
+                buf.append(s)
+                if sum(len(x) for x in buf) >= 220:
+                    break
 
-            # 生成摘要(取前 200 字)
-            summary = text[:200] + "..."
+            text = "".join(buf).strip()
+            summary = (text[:200] + "...") if len(text) > 200 else text
             summaries.append(f"第 {i} 章摘要:{summary}")
 
         return summaries
@@ -396,7 +413,7 @@ class ContextManager:
         relevant = []
 
         for item in all_foreshadowing:
-            if item.get("status") != "未回收":
+            if item.get("status") not in ["未回收", "active", "pending", None, ""]:
                 continue
 
             content = item.get("content", "")
@@ -486,7 +503,7 @@ class ContextManager:
         urgent = []
 
         for item in all_foreshadowing:
-            if item.get("status") != "未回收":
+            if item.get("status") not in ["未回收", "active", "pending", None, ""]:
                 continue
 
             # 计算已埋章节数(粗略:假设每章对应 1 个章节号增量)
@@ -571,13 +588,21 @@ def main():
 
     args = parser.parse_args()
 
+    # 解析项目根目录(支持从仓库根目录运行)
+    project_root = args.project_root
+    if project_root == '.' and not (Path('.') / '.webnovel' / 'state.json').exists():
+        try:
+            project_root = str(resolve_project_root())
+        except FileNotFoundError:
+            project_root = args.project_root
+
     # 解析角色列表
     characters = None
     if args.characters:
         characters = [c.strip() for c in args.characters.split(',')]
 
     # 创建管理器
-    manager = ContextManager(args.project_root)
+    manager = ContextManager(project_root)
 
     # 加载状态
     if not manager.load_state():
@@ -594,7 +619,10 @@ def main():
         print("\n📄 上下文预览:")
         print(json.dumps(context, ensure_ascii=False, indent=2))
     else:
-        manager.save_context(context, args.output)
+        output_path = args.output
+        if args.output == '.webnovel/context_cache.json' and project_root != '.':
+            output_path = str(Path(project_root) / '.webnovel' / 'context_cache.json')
+        manager.save_context(context, output_path)
 
 if __name__ == "__main__":
     main()

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

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

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

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

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

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

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

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

@@ -49,6 +49,8 @@ from typing import Optional, List, Dict, Tuple
 # 安全修复:导入安全工具函数(P1 MEDIUM)
 # ============================================================================
 from security_utils import create_secure_directory
+from project_locator import resolve_project_root
+from chapter_paths import find_chapter_file
 
 
 class StructuredIndex:
@@ -56,7 +58,10 @@ class StructuredIndex:
 
     def __init__(self, project_root=None):
         if project_root is None:
-            project_root = Path.cwd()
+            try:
+                project_root = resolve_project_root()
+            except FileNotFoundError:
+                project_root = Path.cwd()
         else:
             project_root = Path(project_root)
 
@@ -259,9 +264,8 @@ class StructuredIndex:
         - 增加耗时:~5ms(Hash 计算 + 对比)
         - 仅当检测到变更时才重建(增量成本)
         """
-        chapter_file = self.chapters_dir / f"第{chapter_num:04d}章.md"
-
-        if not chapter_file.exists():
+        chapter_file = find_chapter_file(self.project_root, chapter_num)
+        if chapter_file is None or not chapter_file.exists():
             return  # 文件不存在,跳过
 
         # 计算当前文件 Hash
@@ -363,18 +367,40 @@ class StructuredIndex:
 
         current_chapter = state.get('progress', {}).get('current_chapter', 0)
 
-        # 同步活跃伏笔(未回收)
-        active_plots = state.get('plot_threads', {}).get('active', [])
-        for plot in active_plots:
-            self._index_foreshadowing(plot, current_chapter, status="未回收")
+        plot_threads = state.get('plot_threads', {}) or {}
 
-        # 同步已回收伏笔
-        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()
-        print(f"✅ 伏笔索引已同步:{len(active_plots)} 条活跃 + {len(resolved_plots)} 条已回收")
+        print(f"✅ 伏笔索引已同步:{active_count} 条活跃 + {resolved_count} 条已回收")
 
     def _index_foreshadowing(self, plot: Dict, current_chapter: int, status: str):
         """为单个伏笔建立索引"""
@@ -391,8 +417,8 @@ class StructuredIndex:
             (id, content, location, characters, introduced_chapter, resolved_chapter, status, urgency, updated_at)
             VALUES ((SELECT id FROM foreshadowing_index WHERE content = ?), ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
         """, (
-            plot['description'],  # 用于查重
-            plot['description'],
+            plot.get('description', ''),  # 用于查重
+            plot.get('description', ''),
             location,
             json.dumps(characters, ensure_ascii=False),
             plot.get('introduced_chapter', 0),
@@ -581,10 +607,11 @@ class StructuredIndex:
             return
 
         # 获取所有章节文件
-        chapter_files = sorted(self.chapters_dir.glob("第*.md"))
+        chapter_files = sorted(self.chapters_dir.rglob("第*.md"))
 
         print(f"🔍 发现 {len(chapter_files)} 个章节文件,开始重建索引...")
 
+        seen = set()
         for chapter_file in chapter_files:
             # 提取章节编号
             match = re.search(r'第(\d+)章', chapter_file.name)
@@ -592,6 +619,9 @@ class StructuredIndex:
                 continue
 
             chapter_num = int(match.group(1))
+            if chapter_num in seen:
+                continue
+            seen.add(chapter_num)
 
             # 重建索引
             self._rebuild_chapter_index(chapter_num, chapter_file)
@@ -599,7 +629,7 @@ class StructuredIndex:
         # 同步伏笔索引
         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)
 # ============================================================================
 from security_utils import create_secure_directory
+from project_locator import resolve_state_file
 
 # Windows 编码兼容性修复
 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(
         '--state-file',
-        default='.webnovel/state.json',
-        help='state.json 文件路径(默认: .webnovel/state.json)'
+        default=None,
+        help='state.json 文件路径(可选)。不提供时从项目根目录自动定位为 .webnovel/state.json。'
     )
 
     parser.add_argument(
@@ -491,8 +498,11 @@ def main():
         parser.print_help()
         sys.exit(1)
 
+    # 解析 state.json 路径(支持从仓库根目录运行)
+    state_file_path = resolve_state_file(args.state_file, explicit_project_root=args.project_root)
+
     # 创建更新器
-    updater = StateUpdater(args.state_file, args.dry_run)
+    updater = StateUpdater(str(state_file_path), args.dry_run)
 
     # 加载状态文件
     if not updater.load():
@@ -564,7 +574,7 @@ def main():
         if not args.dry_run:
             print(f"\n💡 提示:")
             print(f"  - 原文件已备份: {updater.backup_file}")
-            print(f"  - 如需回滚,可复制备份文件到 {args.state_file}")
+            print(f"  - 如需回滚,可复制备份文件到 {updater.state_file}")
 
     except Exception as e:
         print(f"\n❌ 更新失败: {e}")

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

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

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

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

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

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