Преглед на файлове

fix: align webnovel state/index/archive schema

lingfengQAQ преди 5 месеца
родител
ревизия
258069ce76

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

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

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

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

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

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

+ 3 - 3
.claude/commands/webnovel-write.md

@@ -779,9 +779,9 @@ python .claude/skills/webnovel-writer/scripts/archive_manager.py --auto-check
 **Purpose**: 防止 state.json 无限增长(200万字长跑保障)
 **Purpose**: 防止 state.json 无限增长(200万字长跑保障)
 
 
 **Archiving Strategy**:
 **Archiving Strategy**:
-- **角色归档**: 超过 50 章未出场的次要角色 → `archive/characters.json`
-- **伏笔归档**: status="已回收" 且超过 20 章的伏笔 → `archive/plot_threads.json`
-- **报告归档**: 超过 50 章的旧审查报告 → `archive/reviews.json`
+- **角色归档**: 超过 50 章未出场的次要角色 → `.webnovel/archive/characters.json`
+- **伏笔归档**: status="已回收" 且超过 20 章的伏笔 → `.webnovel/archive/plot_threads.json`
+- **报告归档**: 超过 50 章的旧审查报告 → `.webnovel/archive/reviews.json`
 
 
 **Trigger Conditions** (满足任一即执行):
 **Trigger Conditions** (满足任一即执行):
 - state.json 大小 ≥ 1 MB
 - state.json 大小 ≥ 1 MB

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

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

+ 121 - 25
.claude/skills/webnovel-writer/scripts/archive_manager.py

@@ -81,8 +81,8 @@ class ArchiveManager:
         self.config = {
         self.config = {
             "character_inactive_threshold": 50,  # 角色超过 50 章未出场视为不活跃
             "character_inactive_threshold": 50,  # 角色超过 50 章未出场视为不活跃
             "plot_resolved_threshold": 20,       # 已回收伏笔超过 20 章后归档
             "plot_resolved_threshold": 20,       # 已回收伏笔超过 20 章后归档
-            "review_old_threshold": 20,          # 审查报告超过 20 章后归档(从 50 降至 20)
-            "file_size_trigger_mb": 0.5,         # state.json 超过 0.5MB 触发归档(从 1.0 降至 0.5)
+            "review_old_threshold": 50,          # 审查报告超过 50 章后归档
+            "file_size_trigger_mb": 1.0,         # state.json 超过 1.0MB 触发强制归档
             "chapter_trigger": 10                # 每 10 章检查一次
             "chapter_trigger": 10                # 每 10 章检查一次
         }
         }
 
 
@@ -152,11 +152,22 @@ class ArchiveManager:
         inactive = []
         inactive = []
         for char in characters:
         for char in characters:
             # 只归档次要角色(importance="minor")
             # 只归档次要角色(importance="minor")
-            if char.get("importance") != "minor":
+            importance = char.get("importance")
+            if not importance:
+                tier = str(char.get("tier", "")).strip()
+                importance = "major" if tier == "核心" else "minor"
+            if importance != "minor":
                 continue
                 continue
 
 
             # 检查最后出场章节
             # 检查最后出场章节
             last_appearance = char.get("last_appearance_chapter", 0)
             last_appearance = char.get("last_appearance_chapter", 0)
+            try:
+                last_appearance = int(last_appearance)
+            except (TypeError, ValueError):
+                last_appearance = 0
+            if last_appearance <= 0:
+                continue
+
             inactive_chapters = current_chapter - last_appearance
             inactive_chapters = current_chapter - last_appearance
 
 
             if inactive_chapters >= threshold:
             if inactive_chapters >= threshold:
@@ -171,21 +182,48 @@ class ArchiveManager:
     def identify_resolved_plot_threads(self, state):
     def identify_resolved_plot_threads(self, state):
         """识别可归档的已回收伏笔"""
         """识别可归档的已回收伏笔"""
         current_chapter = state.get("progress", {}).get("current_chapter", 0)
         current_chapter = state.get("progress", {}).get("current_chapter", 0)
-        plot_threads = state.get("plot_threads", {}).get("active", [])
-        resolved = state.get("plot_threads", {}).get("resolved", [])
+        plot_threads = state.get("plot_threads", {}) or {}
+        foreshadowing = plot_threads.get("foreshadowing", []) or []
+        resolved_legacy = plot_threads.get("resolved", []) or []
         threshold = self.config["plot_resolved_threshold"]
         threshold = self.config["plot_resolved_threshold"]
 
 
         archivable = []
         archivable = []
-        for thread in resolved:
-            resolved_chapter = thread.get("resolved_chapter", 0)
-            chapters_since_resolved = current_chapter - resolved_chapter
-
-            if chapters_since_resolved >= threshold:
-                archivable.append({
-                    "thread": thread,
-                    "chapters_since_resolved": chapters_since_resolved,
-                    "resolved_chapter": resolved_chapter
-                })
+        # 新格式:plot_threads.foreshadowing(用 status 标识是否已回收)
+        if isinstance(foreshadowing, list):
+            for item in foreshadowing:
+                if not isinstance(item, dict):
+                    continue
+                status = str(item.get("status", "")).strip()
+                if status not in ["已回收", "resolved"]:
+                    continue
+                try:
+                    resolved_chapter = int(item.get("resolved_chapter", 0))
+                except (TypeError, ValueError):
+                    continue
+                chapters_since_resolved = current_chapter - resolved_chapter
+                if chapters_since_resolved >= threshold:
+                    archivable.append({
+                        "thread": item,
+                        "chapters_since_resolved": chapters_since_resolved,
+                        "resolved_chapter": resolved_chapter
+                    })
+
+        # 旧格式兼容:plot_threads.resolved(直接存已回收列表)
+        if isinstance(resolved_legacy, list):
+            for item in resolved_legacy:
+                if not isinstance(item, dict):
+                    continue
+                try:
+                    resolved_chapter = int(item.get("resolved_chapter", 0))
+                except (TypeError, ValueError):
+                    continue
+                chapters_since_resolved = current_chapter - resolved_chapter
+                if chapters_since_resolved >= threshold:
+                    archivable.append({
+                        "thread": item,
+                        "chapters_since_resolved": chapters_since_resolved,
+                        "resolved_chapter": resolved_chapter
+                    })
 
 
         return archivable
         return archivable
 
 
@@ -195,9 +233,47 @@ class ArchiveManager:
         reviews = state.get("review_checkpoints", [])
         reviews = state.get("review_checkpoints", [])
         threshold = self.config["review_old_threshold"]
         threshold = self.config["review_old_threshold"]
 
 
+        def _parse_end_chapter(review: dict) -> int:
+            # 新格式:{"chapters":"5-6","report":"...","reviewed_at":"..."}
+            chapters = review.get("chapters")
+            if isinstance(chapters, str):
+                parts = [p.strip() for p in chapters.replace("—", "-").split("-") if p.strip()]
+                if parts:
+                    try:
+                        return int(parts[-1])
+                    except ValueError:
+                        pass
+
+            # 旧格式:{"chapter_range":[5,6], "date":"..."}
+            cr = review.get("chapter_range")
+            if isinstance(cr, (list, tuple)) and len(cr) >= 2:
+                try:
+                    return int(cr[1])
+                except (TypeError, ValueError):
+                    pass
+
+            # 兜底:从 report 文件名里抓 "Ch5-6" 或 "第005-006"
+            report = review.get("report")
+            if isinstance(report, str):
+                import re
+                m = re.search(r"Ch(\d+)[-–—](\d+)", report)
+                if m:
+                    try:
+                        return int(m.group(2))
+                    except ValueError:
+                        pass
+                m = re.search(r"第(\d+)[-–—](\d+)章", report)
+                if m:
+                    try:
+                        return int(m.group(2))
+                    except ValueError:
+                        pass
+
+            return 0
+
         old_reviews = []
         old_reviews = []
         for review in reviews:
         for review in reviews:
-            review_chapter = review.get("chapter_range", [0, 0])[1]  # 取结束章节
+            review_chapter = _parse_end_chapter(review)
             chapters_since_review = current_chapter - review_chapter
             chapters_since_review = current_chapter - review_chapter
 
 
             if chapters_since_review >= threshold:
             if chapters_since_review >= threshold:
@@ -296,18 +372,37 @@ class ArchiveManager:
 
 
         # 移除已归档的伏笔
         # 移除已归档的伏笔
         if resolved_threads:
         if resolved_threads:
-            thread_ids = {item["thread"]["description"] for item in resolved_threads}
-            state["plot_threads"]["resolved"] = [
-                thread for thread in state["plot_threads"]["resolved"]
-                if thread["description"] not in thread_ids
-            ]
+            thread_ids = {
+                (item.get("thread", {}) or {}).get("content") or (item.get("thread", {}) or {}).get("description")
+                for item in resolved_threads
+            }
+            thread_ids = {t for t in thread_ids if isinstance(t, str) and t.strip()}
+
+            plot_threads = state.get("plot_threads", {}) or {}
+            if isinstance(plot_threads.get("foreshadowing"), list):
+                plot_threads["foreshadowing"] = [
+                    t for t in plot_threads["foreshadowing"]
+                    if not isinstance(t, dict) or (t.get("content") or t.get("description")) not in thread_ids
+                ]
+            if isinstance(plot_threads.get("resolved"), list):
+                plot_threads["resolved"] = [
+                    t for t in plot_threads["resolved"]
+                    if not isinstance(t, dict) or (t.get("content") or t.get("description")) not in thread_ids
+                ]
+            state["plot_threads"] = plot_threads
 
 
         # 移除旧审查报告
         # 移除旧审查报告
         if old_reviews:
         if old_reviews:
-            review_dates = {item["review"]["date"] for item in old_reviews}
+            review_keys = set()
+            for item in old_reviews:
+                review = item.get("review", {}) or {}
+                key = review.get("report") or review.get("reviewed_at") or review.get("date")
+                if isinstance(key, str) and key.strip():
+                    review_keys.add(key)
+
             state["review_checkpoints"] = [
             state["review_checkpoints"] = [
-                review for review in state["review_checkpoints"]
-                if review["date"] not in review_dates
+                review for review in state.get("review_checkpoints", [])
+                if (review.get("report") or review.get("reviewed_at") or review.get("date")) not in review_keys
             ]
             ]
 
 
         return state
         return state
@@ -354,7 +449,8 @@ class ArchiveManager:
             if resolved_threads:
             if resolved_threads:
                 print("\n   已回收伏笔:")
                 print("\n   已回收伏笔:")
                 for item in resolved_threads[:5]:
                 for item in resolved_threads[:5]:
-                    print(f"   - {item['thread']['description'][:30]}... (已回收 {item['chapters_since_resolved']} 章)")
+                    desc = item["thread"].get("content") or item["thread"].get("description") or ""
+                    print(f"   - {str(desc)[:30]}... (已回收 {item['chapters_since_resolved']} 章)")
             if old_reviews:
             if old_reviews:
                 print("\n   旧审查报告:")
                 print("\n   旧审查报告:")
                 for item in old_reviews[:5]:
                 for item in old_reviews[:5]:

+ 39 - 16
.claude/skills/webnovel-writer/scripts/extract_entities.py

@@ -395,9 +395,18 @@ def update_state_json(
     default_planted_chapter: Optional[int] = None,
     default_planted_chapter: Optional[int] = None,
 ):
 ):
     """更新 state.json 中的实体记录(支持层级分类/金手指技能/伏笔结构化)"""
     """更新 state.json 中的实体记录(支持层级分类/金手指技能/伏笔结构化)"""
+
+    def _to_int(value: Any, default: int = 0) -> int:
+        try:
+            return int(value)
+        except (TypeError, ValueError):
+            return default
+
     with open(state_file, 'r', encoding='utf-8') as f:
     with open(state_file, 'r', encoding='utf-8') as f:
         state = json.load(f)
         state = json.load(f)
 
 
+    first_seen_chapter = _to_int(default_planted_chapter, 0)
+
     # 确保存在实体列表
     # 确保存在实体列表
     if 'entities' not in state:
     if 'entities' not in state:
         state['entities'] = {
         state['entities'] = {
@@ -411,33 +420,44 @@ def update_state_json(
     # 确保存在金手指技能列表
     # 确保存在金手指技能列表
     if 'protagonist_state' not in state:
     if 'protagonist_state' not in state:
         state['protagonist_state'] = {}
         state['protagonist_state'] = {}
-    if 'golden_finger' not in state['protagonist_state']:
-        state['protagonist_state']['golden_finger'] = {
-            "name": "",
-            "skills": [],
-            "level": 1
-        }
+    golden_finger = state['protagonist_state'].get('golden_finger')
+    if not isinstance(golden_finger, dict):
+        golden_finger = {}
+        state['protagonist_state']['golden_finger'] = golden_finger
+    golden_finger.setdefault("name", "")
+    golden_finger.setdefault("level", 1)
+    golden_finger.setdefault("cooldown", 0)
+    golden_finger.setdefault("skills", [])
 
 
     for entity in entities:
     for entity in entities:
         entity_type = entity['type']
         entity_type = entity['type']
         entity_tier = entity.get('tier', '支线')
         entity_tier = entity.get('tier', '支线')
 
 
         if entity_type == "角色":
         if entity_type == "角色":
-            if entity['name'] not in [c.get('name') for c in state['entities']['characters']]:
-                state['entities']['characters'].append({
-                    "name": entity['name'],
-                    "desc": entity['desc'],
-                    "category": categorize_character(entity['desc']),
-                    "tier": entity_tier,
-                    "first_appearance": entity.get('source_file', ''),
-                    "added_at": datetime.now().strftime('%Y-%m-%d')
-                })
+            chars = state['entities']['characters']
+            record = next((c for c in chars if c.get('name') == entity['name']), None)
+            if record is None:
+                record = {"name": entity['name']}
+                chars.append(record)
+
+            desc = entity['desc']
+            record.setdefault("desc", desc)
+            record.setdefault("description", desc)  # 兼容 structured_index
+            record.setdefault("category", categorize_character(desc))
+            record.setdefault("tier", entity_tier)
+            record.setdefault("importance", "major" if entity_tier == "核心" else "minor")
+            record.setdefault("first_appearance", entity.get('source_file', ''))
+            if first_seen_chapter:
+                record.setdefault("first_appearance_chapter", first_seen_chapter)
+                record["last_appearance_chapter"] = max(_to_int(record.get("last_appearance_chapter"), 0), first_seen_chapter)
+            record.setdefault("added_at", datetime.now().strftime('%Y-%m-%d'))
 
 
         elif entity_type == "地点":
         elif entity_type == "地点":
             if entity['name'] not in [l.get('name') for l in state['entities']['locations']]:
             if entity['name'] not in [l.get('name') for l in state['entities']['locations']]:
                 state['entities']['locations'].append({
                 state['entities']['locations'].append({
                     "name": entity['name'],
                     "name": entity['name'],
                     "desc": entity['desc'],
                     "desc": entity['desc'],
+                    "description": entity['desc'],
                     "tier": entity_tier,
                     "tier": entity_tier,
                     "first_appearance": entity.get('source_file', ''),
                     "first_appearance": entity.get('source_file', ''),
                     "added_at": datetime.now().strftime('%Y-%m-%d')
                     "added_at": datetime.now().strftime('%Y-%m-%d')
@@ -448,6 +468,7 @@ def update_state_json(
                 state['entities']['items'].append({
                 state['entities']['items'].append({
                     "name": entity['name'],
                     "name": entity['name'],
                     "desc": entity['desc'],
                     "desc": entity['desc'],
+                    "description": entity['desc'],
                     "tier": entity_tier,
                     "tier": entity_tier,
                     "first_appearance": entity.get('source_file', ''),
                     "first_appearance": entity.get('source_file', ''),
                     "added_at": datetime.now().strftime('%Y-%m-%d')
                     "added_at": datetime.now().strftime('%Y-%m-%d')
@@ -458,6 +479,7 @@ def update_state_json(
                 state['entities']['factions'].append({
                 state['entities']['factions'].append({
                     "name": entity['name'],
                     "name": entity['name'],
                     "desc": entity['desc'],
                     "desc": entity['desc'],
+                    "description": entity['desc'],
                     "tier": entity_tier,
                     "tier": entity_tier,
                     "first_appearance": entity.get('source_file', ''),
                     "first_appearance": entity.get('source_file', ''),
                     "added_at": datetime.now().strftime('%Y-%m-%d')
                     "added_at": datetime.now().strftime('%Y-%m-%d')
@@ -468,6 +490,7 @@ def update_state_json(
                 state['entities']['techniques'].append({
                 state['entities']['techniques'].append({
                     "name": entity['name'],
                     "name": entity['name'],
                     "desc": entity['desc'],
                     "desc": entity['desc'],
+                    "description": entity['desc'],
                     "tier": entity_tier,
                     "tier": entity_tier,
                     "first_appearance": entity.get('source_file', ''),
                     "first_appearance": entity.get('source_file', ''),
                     "added_at": datetime.now().strftime('%Y-%m-%d')
                     "added_at": datetime.now().strftime('%Y-%m-%d')
@@ -532,7 +555,7 @@ def update_state_json(
             if found is None:
             if found is None:
                 existing.append({
                 existing.append({
                     "content": content,
                     "content": content,
-                    "status": "active",
+                    "status": "未回收",
                     "tier": tier,
                     "tier": tier,
                     "planted_chapter": planted,
                     "planted_chapter": planted,
                     "target_chapter": target,
                     "target_chapter": target,

+ 6 - 1
.claude/skills/webnovel-writer/scripts/init_project.py

@@ -203,7 +203,12 @@ def init_project(
     # 读取内置模板(可选)
     # 读取内置模板(可选)
     script_dir = Path(__file__).resolve().parent
     script_dir = Path(__file__).resolve().parent
     templates_dir = script_dir.parent / "templates"
     templates_dir = script_dir.parent / "templates"
-    genre_template = _read_text_if_exists(templates_dir / "genres" / f"{genre}.md")
+    genre_key = (genre or "").strip()
+    genre_template_key = {
+        "修仙/玄幻": "修仙",
+        "玄幻": "修仙",
+    }.get(genre_key, genre_key)
+    genre_template = _read_text_if_exists(templates_dir / "genres" / f"{genre_template_key}.md")
     golden_finger_templates = _read_text_if_exists(templates_dir / "golden-finger-templates.md")
     golden_finger_templates = _read_text_if_exists(templates_dir / "golden-finger-templates.md")
 
 
     # 基础文件(只在缺失时生成,避免覆盖已有内容)
     # 基础文件(只在缺失时生成,避免覆盖已有内容)

+ 119 - 4
.claude/skills/webnovel-writer/scripts/structured_index.py

@@ -41,6 +41,7 @@ import argparse
 import sqlite3
 import sqlite3
 import hashlib
 import hashlib
 import re
 import re
+import tempfile
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 from typing import Optional, List, Dict, Tuple
 from typing import Optional, List, Dict, Tuple
@@ -219,6 +220,87 @@ class StructuredIndex:
         self.conn.commit()
         self.conn.commit()
         print(f"✅ 章节索引已更新:Ch{chapter_num} - {metadata['title']}")
         print(f"✅ 章节索引已更新:Ch{chapter_num} - {metadata['title']}")
 
 
+    def bump_character_last_appearance_in_state(self, chapter_num: int, character_names: List[str]) -> int:
+        """将本章出场角色同步回 state.json 的 last_appearance_chapter(轻量级)"""
+        if not character_names:
+            return 0
+        if not self.state_file.exists():
+            return 0
+
+        try:
+            with open(self.state_file, 'r', encoding='utf-8') as f:
+                state = json.load(f)
+        except json.JSONDecodeError:
+            return 0
+
+        entities = state.get("entities", {}) or {}
+        characters = entities.get("characters", [])
+        if not isinstance(characters, list):
+            return 0
+
+        name_set = {str(n).strip() for n in character_names if str(n).strip()}
+        if not name_set:
+            return 0
+
+        updated = 0
+        changed = False
+        for char in characters:
+            if not isinstance(char, dict):
+                continue
+            name = str(char.get("name", "")).strip()
+            if not name or name not in name_set:
+                continue
+
+            tier = str(char.get("tier", "")).strip()
+            if "importance" not in char:
+                char["importance"] = "major" if tier == "核心" else "minor"
+                changed = True
+
+            if "description" not in char and isinstance(char.get("desc"), str):
+                char["description"] = char.get("desc", "")
+                changed = True
+
+            prev = char.get("last_appearance_chapter")
+            try:
+                prev_int = int(prev)
+            except (TypeError, ValueError):
+                prev_int = 0
+
+            new_last = max(prev_int, int(chapter_num))
+            if new_last != prev_int:
+                char["last_appearance_chapter"] = new_last
+                updated += 1
+                changed = True
+
+            if not char.get("first_appearance_chapter"):
+                char["first_appearance_chapter"] = int(chapter_num)
+                changed = True
+
+        if not changed:
+            return 0
+
+        tmp_path = None
+        try:
+            with tempfile.NamedTemporaryFile(
+                mode="w",
+                encoding="utf-8",
+                suffix=".tmp",
+                delete=False,
+                dir=str(self.state_file.parent),
+            ) as tf:
+                tmp_path = Path(tf.name)
+                json.dump(state, tf, ensure_ascii=False, indent=2)
+                tf.write("\n")
+            os.replace(str(tmp_path), str(self.state_file))
+        finally:
+            if tmp_path and tmp_path.exists():
+                try:
+                    tmp_path.unlink()
+                except OSError:
+                    pass
+
+        return updated
+
     def query_chapters_by_location(self, location: str, limit: int = 10) -> List[Tuple]:
     def query_chapters_by_location(self, location: str, limit: int = 10) -> List[Tuple]:
         """O(log n) 查询:返回该地点的最近 N 章
         """O(log n) 查询:返回该地点的最近 N 章
 
 
@@ -470,6 +552,32 @@ class StructuredIndex:
 
 
     def _index_character(self, char: Dict, status: str = 'active'):
     def _index_character(self, char: Dict, status: str = 'active'):
         """为单个角色建立索引"""
         """为单个角色建立索引"""
+        description = char.get('description') or char.get('desc') or ''
+        tier = str(char.get('tier', '') or '').strip()
+        importance = char.get('importance') or ('major' if tier == '核心' else 'minor')
+
+        first_appearance = char.get('first_appearance_chapter', 0) or 0
+        try:
+            first_appearance = int(first_appearance)
+        except (TypeError, ValueError):
+            first_appearance = 0
+
+        if first_appearance == 0:
+            src = char.get('first_appearance')
+            if isinstance(src, str):
+                m = re.search(r'第(\d+)章', src)
+                if m:
+                    try:
+                        first_appearance = int(m.group(1))
+                    except ValueError:
+                        first_appearance = 0
+
+        last_appearance = char.get('last_appearance_chapter', 0) or first_appearance
+        try:
+            last_appearance = int(last_appearance)
+        except (TypeError, ValueError):
+            last_appearance = first_appearance
+
         self.conn.execute("""
         self.conn.execute("""
             INSERT OR REPLACE INTO characters
             INSERT OR REPLACE INTO characters
             (name, description, personality, importance, power_level,
             (name, description, personality, importance, power_level,
@@ -477,12 +585,12 @@ class StructuredIndex:
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
         """, (
         """, (
             char.get('name', ''),
             char.get('name', ''),
-            char.get('description', ''),
+            description,
             char.get('personality', ''),
             char.get('personality', ''),
-            char.get('importance', 'minor'),
+            importance,
             char.get('power_level', ''),
             char.get('power_level', ''),
-            char.get('first_appearance_chapter', 0),
-            char.get('last_appearance_chapter', 0),
+            first_appearance,
+            last_appearance,
             status
             status
         ))
         ))
 
 
@@ -628,6 +736,7 @@ class StructuredIndex:
 
 
         # 同步伏笔索引
         # 同步伏笔索引
         self.sync_foreshadowing_from_state()
         self.sync_foreshadowing_from_state()
+        self.sync_characters_from_state()
 
 
         print(f"✅ 批量重建完成:{len(seen)} 章")
         print(f"✅ 批量重建完成:{len(seen)} 章")
 
 
@@ -723,6 +832,8 @@ def main():
 
 
                 # 同步伏笔索引
                 # 同步伏笔索引
                 index.sync_foreshadowing_from_state()
                 index.sync_foreshadowing_from_state()
+                index.bump_character_last_appearance_in_state(args.update_chapter, metadata.get("characters", []))
+                index.sync_characters_from_state()
 
 
             except json.JSONDecodeError as e:
             except json.JSONDecodeError as e:
                 print(f"❌ JSON 解析失败: {e}")
                 print(f"❌ JSON 解析失败: {e}")
@@ -746,6 +857,8 @@ def main():
 
 
                 # 同步伏笔索引
                 # 同步伏笔索引
                 index.sync_foreshadowing_from_state()
                 index.sync_foreshadowing_from_state()
+                index.bump_character_last_appearance_in_state(args.update_chapter, metadata.get("characters", []))
+                index.sync_characters_from_state()
 
 
             except json.JSONDecodeError as e:
             except json.JSONDecodeError as e:
                 print(f"❌ JSON 解析失败: {e}")
                 print(f"❌ JSON 解析失败: {e}")
@@ -770,6 +883,8 @@ def main():
 
 
             # 同步伏笔索引
             # 同步伏笔索引
             index.sync_foreshadowing_from_state()
             index.sync_foreshadowing_from_state()
+            index.bump_character_last_appearance_in_state(args.update_chapter, metadata.get("characters", []))
+            index.sync_characters_from_state()
 
 
         else:
         else:
             print("❌ 缺少参数:--metadata-file (推荐) / --metadata-json / --metadata")
             print("❌ 缺少参数:--metadata-file (推荐) / --metadata-json / --metadata")

+ 10 - 5
.claude/skills/webnovel-writer/scripts/update_state.py

@@ -208,11 +208,16 @@ class StateUpdater:
 
 
     def update_golden_finger(self, name: str, level: int, cooldown: int):
     def update_golden_finger(self, name: str, level: int, cooldown: int):
         """更新金手指状态"""
         """更新金手指状态"""
-        self.state["protagonist_state"]["golden_finger"] = {
-            "name": name,
-            "level": level,
-            "cooldown": cooldown
-        }
+        ps = self.state.setdefault("protagonist_state", {})
+        golden_finger = ps.get("golden_finger")
+        if not isinstance(golden_finger, dict):
+            golden_finger = {}
+            ps["golden_finger"] = golden_finger
+
+        golden_finger.setdefault("skills", [])
+        golden_finger["name"] = name
+        golden_finger["level"] = level
+        golden_finger["cooldown"] = cooldown
         print(f"📝 更新金手指: {name} Lv.{level}, 冷却: {cooldown}天")
         print(f"📝 更新金手指: {name} Lv.{level}, 冷却: {cooldown}天")
 
 
     def update_relationship(self, char_name: str, key: str, value: Any):
     def update_relationship(self, char_name: str, key: str, value: Any):