소스 검색

fix: prune init templates and add master outline sync

lingfengQAQ 1 개월 전
부모
커밋
77389c46de

+ 70 - 0
webnovel-writer/scripts/data_modules/tests/test_init_project_pruning.py

@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+
+def test_init_skips_dead_templates_and_empty_libraries_for_single_protagonist(tmp_path, monkeypatch):
+    import init_project as init_project_module
+
+    monkeypatch.setattr(init_project_module, "is_git_available", lambda: False)
+    project_root = tmp_path / "book"
+
+    init_project_module.init_project(
+        str(project_root),
+        title="测试书",
+        genre="仙侠",
+        protagonist_name="陆鸣",
+        protagonist_structure="单主角+辅助视角",
+        heroine_config="无女主",
+        target_chapters=50,
+    )
+
+    assert (project_root / "设定集" / "主角卡.md").is_file()
+    assert not (project_root / "设定集" / "主角组.md").exists()
+    assert not (project_root / "设定集" / "女主卡.md").exists()
+    assert not (project_root / "设定集" / "金手指设计.md").exists()
+    assert not (project_root / "设定集" / "复合题材-融合逻辑.md").exists()
+    assert not (project_root / "大纲" / "爽点规划.md").exists()
+    assert not (project_root / "设定集" / "角色库").exists()
+    assert not (project_root / "设定集" / "物品库").exists()
+    assert not (project_root / "设定集" / "其他设定").exists()
+
+
+def test_init_master_outline_does_not_prefill_future_volume_rows(tmp_path, monkeypatch):
+    import init_project as init_project_module
+
+    monkeypatch.setattr(init_project_module, "is_git_available", lambda: False)
+    project_root = tmp_path / "book"
+
+    init_project_module.init_project(
+        str(project_root),
+        title="测试书",
+        genre="仙侠",
+        protagonist_name="陆鸣",
+        target_chapters=600,
+    )
+
+    summary = (project_root / "大纲" / "总纲.md").read_text(encoding="utf-8")
+    assert "| 1 |" in summary
+    assert "| 2 |" not in summary
+    assert "| 20 |" not in summary
+
+
+def test_init_generates_conditional_protagonist_group_and_heroine(tmp_path, monkeypatch):
+    import init_project as init_project_module
+
+    monkeypatch.setattr(init_project_module, "is_git_available", lambda: False)
+    project_root = tmp_path / "book"
+
+    init_project_module.init_project(
+        str(project_root),
+        title="测试书",
+        genre="仙侠",
+        protagonist_name="陆鸣",
+        protagonist_structure="双主角",
+        heroine_config="单女主",
+        heroine_names="苏云",
+        target_chapters=50,
+    )
+
+    assert (project_root / "设定集" / "主角组.md").is_file()
+    assert (project_root / "设定集" / "女主卡.md").is_file()

+ 201 - 0
webnovel-writer/scripts/data_modules/tests/test_update_master_outline.py

@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+
+def _ensure_scripts_on_path() -> None:
+    scripts_dir = Path(__file__).resolve().parents[2]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
+
+def _write_project(project_root: Path) -> None:
+    outline_dir = project_root / "大纲"
+    outline_dir.mkdir(parents=True)
+    (outline_dir / "总纲.md").write_text(
+        "\n".join(
+            [
+                "# 总纲",
+                "",
+                "## 卷划分",
+                "| 卷号 | 卷名 | 章节范围 | 核心冲突 | 卷末高潮 |",
+                "|------|------|----------|----------|----------|",
+                "| 1 | 阎王债 | 第1-50章 | 逼债调查 | 债契真相浮出 |",
+                "",
+                "## 伏笔表",
+                "| 伏笔内容 | 埋设章 | 回收章 | 层级 |",
+                "|----------|--------|--------|------|",
+                "| 旧伏笔 | 第1章 | | 卷级 |",
+                "",
+            ]
+        ),
+        encoding="utf-8",
+    )
+    for name in ("第1卷-节拍表.md", "第1卷-时间线.md", "第1卷-详细大纲.md"):
+        (outline_dir / name).write_text(f"# {name}\n有效内容\n", encoding="utf-8")
+
+
+def _write_writeback(project_root: Path, *, include_free_text: bool = False) -> Path:
+    payload = {
+        "next_volume_anchor": {
+            "volume": 2,
+            "volume_name": "黑水账",
+            "core_conflict": "追查黑水账簿背后的债脉",
+            "volume_end_climax": "账簿主人在宗门大比现身",
+        },
+        "foreshadow_writeback": [
+            {
+                "content": "债契背面的红印仍未解释",
+                "buried_chapter": "第12章",
+                "payoff_chapter": "",
+                "level": "卷级",
+            }
+        ],
+        "open_loop_writeback": [
+            {"content": "苏云身份与阎王债源头仍未闭合"}
+        ],
+    }
+    if include_free_text:
+        payload["notes"] = "自由文本里提到一个不应被追加的隐藏黑手"
+    path = project_root / "大纲" / "第1卷-总纲写回.json"
+    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+    return path
+
+
+def test_next_volume_anchor_writeback(tmp_path):
+    _ensure_scripts_on_path()
+    from update_master_outline import sync_master_outline
+
+    _write_project(tmp_path)
+    _write_writeback(tmp_path)
+
+    result = sync_master_outline(tmp_path, 1)
+
+    summary = (tmp_path / "大纲" / "总纲.md").read_text(encoding="utf-8")
+    assert result["next_volume"] == 2
+    assert "| 2 | 黑水账 |  | 追查黑水账簿背后的债脉 | 账簿主人在宗门大比现身 |" in summary
+    assert "| 3 |" not in summary
+
+
+def test_structured_foreshadow_append_only(tmp_path):
+    _ensure_scripts_on_path()
+    from update_master_outline import sync_master_outline
+
+    _write_project(tmp_path)
+    _write_writeback(tmp_path, include_free_text=True)
+
+    result = sync_master_outline(tmp_path, 1)
+
+    summary = (tmp_path / "大纲" / "总纲.md").read_text(encoding="utf-8")
+    assert result["structured_items_appended"] == 2
+    assert "债契背面的红印仍未解释" in summary
+    assert "苏云身份与阎王债源头仍未闭合" in summary
+    assert "隐藏黑手" not in summary
+    assert "|  |  |  |  |" not in summary
+
+
+def test_master_outline_sync_does_not_create_next_volume_detail_files(tmp_path):
+    _ensure_scripts_on_path()
+    from update_master_outline import sync_master_outline
+
+    _write_project(tmp_path)
+    _write_writeback(tmp_path)
+
+    sync_master_outline(tmp_path, 1)
+
+    assert not (tmp_path / "大纲" / "第2卷-详细大纲.md").exists()
+    assert not (tmp_path / "大纲" / "第2卷-节拍表.md").exists()
+    assert not (tmp_path / "大纲" / "第2卷-时间线.md").exists()
+
+
+def test_master_outline_sync_requires_completed_current_volume_artifacts(tmp_path):
+    _ensure_scripts_on_path()
+    from update_master_outline import MasterOutlineSyncError, sync_master_outline
+
+    _write_project(tmp_path)
+    _write_writeback(tmp_path)
+    (tmp_path / "大纲" / "第1卷-时间线.md").unlink()
+
+    with pytest.raises(MasterOutlineSyncError, match="artifacts are incomplete"):
+        sync_master_outline(tmp_path, 1)
+
+
+def test_master_outline_sync_rejects_noncanonical_writeback_source(tmp_path):
+    _ensure_scripts_on_path()
+    from update_master_outline import MasterOutlineSyncError, sync_master_outline
+
+    _write_project(tmp_path)
+    other = tmp_path / "大纲" / "手工备注.json"
+    other.write_text(
+        json.dumps(
+            {
+                "next_volume_anchor": {
+                    "volume": 2,
+                    "volume_name": "黑水账",
+                    "core_conflict": "追查黑水账簿背后的债脉",
+                    "volume_end_climax": "账簿主人在宗门大比现身",
+                }
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+
+    with pytest.raises(MasterOutlineSyncError, match="structured planning file"):
+        sync_master_outline(tmp_path, 1, writeback_file=other)
+
+
+def test_webnovel_master_outline_sync_cli_forwards_project_root(monkeypatch, tmp_path):
+    _ensure_scripts_on_path()
+    import data_modules.webnovel as webnovel_module
+
+    project_root = (tmp_path / "book").resolve()
+    called = {}
+
+    def _fake_resolve(explicit_project_root=None):
+        return project_root
+
+    def _fake_run_script(script_name, argv):
+        called["script_name"] = script_name
+        called["argv"] = list(argv)
+        return 0
+
+    monkeypatch.setattr(webnovel_module, "_resolve_root", _fake_resolve)
+    monkeypatch.setattr(webnovel_module, "_run_script", _fake_run_script)
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "webnovel",
+            "--project-root",
+            str(tmp_path),
+            "master-outline-sync",
+            "--volume",
+            "1",
+            "--writeback-file",
+            "大纲/第1卷-总纲写回.json",
+            "--format",
+            "text",
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        webnovel_module.main()
+
+    assert int(exc.value.code or 0) == 0
+    assert called["script_name"] == "update_master_outline.py"
+    assert called["argv"] == [
+        "--project-root",
+        str(project_root),
+        "--volume",
+        "1",
+        "--format",
+        "text",
+        "--writeback-file",
+        "大纲/第1卷-总纲写回.json",
+    ]

+ 18 - 95
webnovel-writer/scripts/init_project.py

@@ -111,6 +111,18 @@ def _parse_tier_map(raw: str) -> Dict[str, str]:
     return result
 
 
+def _needs_protagonist_group(protagonist_structure: str) -> bool:
+    text = (protagonist_structure or "").strip()
+    return any(marker in text for marker in ("主角组", "双主角", "多主角", "群像主角"))
+
+
+def _needs_heroine_card(heroine_config: str, heroine_names: str) -> bool:
+    text = (heroine_config or "").strip().lower()
+    if text in {"无", "无女主", "none", "no heroine"}:
+        return False
+    return bool((heroine_names or "").strip() or text)
+
+
 def _render_team_rows(names: List[str], roles: List[str]) -> List[str]:
     rows = []
     for idx, name in enumerate(names):
@@ -200,7 +212,7 @@ def _build_master_outline(target_chapters: int, *, chapters_per_volume: int = 50
 
 
 def _inject_volume_rows(template_text: str, target_chapters: int, *, chapters_per_volume: int = 50) -> str:
-    """在总纲模板的卷表中注入卷行(若存在表头)。"""
+    """在总纲模板的卷表中只注入首卷行(后续卷由规划完成后写回)。"""
     lines = template_text.splitlines()
     header_idx = None
     for i, line in enumerate(lines):
@@ -211,12 +223,8 @@ def _inject_volume_rows(template_text: str, target_chapters: int, *, chapters_pe
         return template_text
 
     insert_idx = header_idx + 2 if header_idx + 1 < len(lines) else len(lines)
-    volumes = (target_chapters - 1) // chapters_per_volume + 1 if target_chapters > 0 else 1
-    rows = []
-    for v in range(1, volumes + 1):
-        start = (v - 1) * chapters_per_volume + 1
-        end = min(v * chapters_per_volume, target_chapters)
-        rows.append(f"| {v} | | 第{start}-{end}章 | | |")
+    end = min(chapters_per_volume, target_chapters) if target_chapters > 0 else chapters_per_volume
+    rows = [f"| 1 | | 第1-{end}章 | | |"]
 
     # 避免重复插入(若模板已有数据行)
     existing = {line.strip() for line in lines}
@@ -272,11 +280,7 @@ def init_project(
         ".webnovel/backups",
         ".webnovel/archive",
         ".webnovel/summaries",
-        "设定集/角色库/主要角色",
-        "设定集/角色库/次要角色",
-        "设定集/角色库/反派角色",
-        "设定集/物品库",
-        "设定集/其他设定",
+        "设定集",
         "大纲",
         "正文",
         "审查报告",
@@ -369,15 +373,12 @@ def init_project(
         if template_text:
             genre_templates.append(template_text.strip())
     genre_template = "\n\n---\n\n".join(genre_templates)
-    golden_finger_templates = _read_text_if_exists(templates_dir / "golden-finger-templates.md")
     output_worldview = _read_text_if_exists(output_templates_dir / "设定集-世界观.md")
     output_power = _read_text_if_exists(output_templates_dir / "设定集-力量体系.md")
     output_protagonist = _read_text_if_exists(output_templates_dir / "设定集-主角卡.md")
     output_heroine = _read_text_if_exists(output_templates_dir / "设定集-女主卡.md")
     output_team = _read_text_if_exists(output_templates_dir / "设定集-主角组.md")
-    output_golden_finger = _read_text_if_exists(output_templates_dir / "设定集-金手指.md")
     output_outline = _read_text_if_exists(output_templates_dir / "大纲-总纲.md")
-    output_fusion = _read_text_if_exists(output_templates_dir / "复合题材-融合逻辑.md")
     output_antagonist = _read_text_if_exists(output_templates_dir / "设定集-反派设计.md")
 
     # 基础文件(只在缺失时生成,避免覆盖已有内容)
@@ -503,7 +504,7 @@ def init_project(
     )
 
     heroine_content = output_heroine.strip() if output_heroine else ""
-    if heroine_content:
+    if heroine_content and _needs_heroine_card(heroine_config, heroine_names):
         heroine_content = _apply_label_replacements(
             heroine_content,
             {
@@ -514,7 +515,7 @@ def init_project(
         _write_text_if_missing(project_path / "设定集" / "女主卡.md", heroine_content)
 
     team_content = output_team.strip() if output_team else ""
-    if team_content:
+    if team_content and _needs_protagonist_group(protagonist_structure):
         names = [n.strip() for n in co_protagonists.split(",") if n.strip()] if co_protagonists else []
         roles = [r.strip() for r in co_protagonist_roles.split(",") if r.strip()] if co_protagonist_roles else []
         if names:
@@ -536,56 +537,6 @@ def init_project(
             team_content,
         )
 
-    golden_finger_content = output_golden_finger.strip() if output_golden_finger else ""
-    if not golden_finger_content:
-        golden_finger_content = "\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",
-            ]
-        ).rstrip() + "\n"
-    else:
-        golden_finger_content = _apply_label_replacements(
-            golden_finger_content,
-            {
-                "类型": golden_finger_type,
-                "读者可见度": gf_visibility,
-                "不可逆代价": gf_irreversible_cost,
-            },
-        )
-    _write_text_if_missing(
-        project_path / "设定集" / "金手指设计.md",
-        golden_finger_content,
-    )
-
-    fusion_content = output_fusion.strip() if output_fusion else ""
-    if fusion_content:
-        _write_text_if_missing(
-            project_path / "设定集" / "复合题材-融合逻辑.md",
-            fusion_content,
-        )
-
     antagonist_content = output_antagonist.strip() if output_antagonist else ""
     if not antagonist_content:
         antagonist_content = "\n".join(
@@ -631,32 +582,6 @@ def init_project(
         outline_content = _build_master_outline(int(target_chapters))
     _write_text_if_missing(project_path / "大纲" / "总纲.md", outline_content)
 
-    _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 | 升级/收获 | 进入主线节奏 |",
-                "",
-            ]
-        ),
-    )
-
     # 生成环境变量模板(不写入真实密钥)
     _write_text_if_missing(
         project_path / ".env.example",
@@ -749,9 +674,7 @@ __pycache__/
     print(" - 设定集/世界观.md")
     print(" - 设定集/力量体系.md")
     print(" - 设定集/主角卡.md")
-    print(" - 设定集/金手指设计.md")
     print(" - 大纲/总纲.md")
-    print(" - 大纲/爽点规划.md")
 
 
 def main() -> None:

+ 312 - 0
webnovel-writer/scripts/update_master_outline.py

@@ -0,0 +1,312 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+from runtime_compat import enable_windows_utf8_stdio
+
+
+REQUIRED_VOLUME_ARTIFACTS = (
+    "第{volume}卷-节拍表.md",
+    "第{volume}卷-时间线.md",
+    "第{volume}卷-详细大纲.md",
+)
+
+
+class MasterOutlineSyncError(RuntimeError):
+    pass
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+    try:
+        payload = json.loads(path.read_text(encoding="utf-8"))
+    except FileNotFoundError as exc:
+        raise MasterOutlineSyncError(f"missing writeback file: {path}") from exc
+    except json.JSONDecodeError as exc:
+        raise MasterOutlineSyncError(f"invalid writeback JSON: {path}: {exc}") from exc
+    if not isinstance(payload, dict):
+        raise MasterOutlineSyncError("writeback JSON must be an object")
+    return payload
+
+
+def _require_current_volume_artifacts(project_root: Path, volume: int) -> list[str]:
+    missing: list[str] = []
+    outline_dir = project_root / "大纲"
+    for pattern in REQUIRED_VOLUME_ARTIFACTS:
+        path = outline_dir / pattern.format(volume=volume)
+        if not path.is_file() or not path.read_text(encoding="utf-8").strip():
+            missing.append(path.relative_to(project_root).as_posix())
+    if missing:
+        raise MasterOutlineSyncError(
+            "current volume planning artifacts are incomplete: " + ", ".join(missing)
+        )
+    return [f.format(volume=volume) for f in REQUIRED_VOLUME_ARTIFACTS]
+
+
+def _resolve_writeback_source(
+    project_root: Path,
+    outline_dir: Path,
+    volume: int,
+    writeback_file: str | Path | None,
+) -> Path:
+    expected = (outline_dir / f"第{volume}卷-总纲写回.json").resolve()
+    if writeback_file:
+        candidate = Path(writeback_file)
+        if not candidate.is_absolute():
+            candidate = project_root / candidate
+        candidate = candidate.resolve()
+        if candidate != expected:
+            raise MasterOutlineSyncError(
+                "writeback source must be the structured planning file: "
+                f"{expected.relative_to(project_root).as_posix()}"
+            )
+        return candidate
+    return expected
+
+
+def _cell(value: Any) -> str:
+    text = "" if value is None else str(value)
+    return text.replace("\n", " ").replace("|", "/").strip()
+
+
+def _split_row(line: str) -> list[str]:
+    return [part.strip() for part in line.strip().strip("|").split("|")]
+
+
+def _render_row(cells: list[Any]) -> str:
+    return "| " + " | ".join(_cell(cell) for cell in cells) + " |"
+
+
+def _normalize_anchor(payload: dict[str, Any], expected_volume: int) -> dict[str, str]:
+    raw = payload.get("next_volume_anchor")
+    if not isinstance(raw, dict):
+        raise MasterOutlineSyncError("writeback JSON missing object field: next_volume_anchor")
+
+    volume_value = raw.get("volume") or raw.get("volume_id") or raw.get("卷号") or expected_volume
+    try:
+        volume = int(volume_value)
+    except (TypeError, ValueError) as exc:
+        raise MasterOutlineSyncError(f"invalid next volume value: {volume_value}") from exc
+    if volume != expected_volume:
+        raise MasterOutlineSyncError(f"next_volume_anchor.volume must be {expected_volume}, got {volume}")
+
+    name = raw.get("volume_name") or raw.get("name") or raw.get("卷名")
+    conflict = raw.get("core_conflict") or raw.get("核心冲突")
+    climax = raw.get("volume_end_climax") or raw.get("end_climax") or raw.get("卷末高潮")
+    if not all(_cell(v) for v in (name, conflict, climax)):
+        raise MasterOutlineSyncError(
+            "next_volume_anchor requires volume_name, core_conflict, and volume_end_climax"
+        )
+    return {
+        "volume": str(expected_volume),
+        "volume_name": _cell(name),
+        "core_conflict": _cell(conflict),
+        "volume_end_climax": _cell(climax),
+        "chapters_range": _cell(raw.get("chapters_range") or raw.get("章节范围") or ""),
+    }
+
+
+def _update_volume_table(text: str, anchor: dict[str, str]) -> tuple[str, bool]:
+    lines = text.splitlines()
+    header_idx = next((i for i, line in enumerate(lines) if line.strip().startswith("| 卷号")), None)
+    new_row = _render_row(
+        [
+            anchor["volume"],
+            anchor["volume_name"],
+            anchor["chapters_range"],
+            anchor["core_conflict"],
+            anchor["volume_end_climax"],
+        ]
+    )
+    if header_idx is None:
+        addition = [
+            "",
+            "## 卷划分",
+            "| 卷号 | 卷名 | 章节范围 | 核心冲突 | 卷末高潮 |",
+            "|------|------|----------|----------|----------|",
+            new_row,
+        ]
+        return "\n".join(lines + addition).rstrip() + "\n", True
+
+    row_start = header_idx + 2
+    row_end = row_start
+    while row_end < len(lines) and lines[row_end].strip().startswith("|"):
+        row_end += 1
+
+    changed = False
+    for idx in range(row_start, row_end):
+        cells = _split_row(lines[idx])
+        if cells and cells[0] == anchor["volume"]:
+            while len(cells) < 5:
+                cells.append("")
+            cells[1] = anchor["volume_name"]
+            if anchor["chapters_range"]:
+                cells[2] = anchor["chapters_range"]
+            cells[3] = anchor["core_conflict"]
+            cells[4] = anchor["volume_end_climax"]
+            rendered = _render_row(cells[:5])
+            changed = rendered != lines[idx]
+            lines[idx] = rendered
+            return "\n".join(lines).rstrip() + "\n", changed
+
+    lines.insert(row_end, new_row)
+    return "\n".join(lines).rstrip() + "\n", True
+
+
+def _structured_writeback_items(payload: dict[str, Any]) -> list[dict[str, str]]:
+    items: list[dict[str, str]] = []
+    for field, default_level in (
+        ("foreshadow_writeback", "伏笔"),
+        ("open_loop_writeback", "持续开放环"),
+    ):
+        raw_items = payload.get(field, [])
+        if raw_items is None:
+            continue
+        if not isinstance(raw_items, list):
+            raise MasterOutlineSyncError(f"{field} must be a list")
+        for raw in raw_items:
+            if not isinstance(raw, dict):
+                raise MasterOutlineSyncError(f"{field} entries must be objects")
+            content = raw.get("content") or raw.get("text") or raw.get("伏笔内容")
+            if not _cell(content):
+                continue
+            items.append(
+                {
+                    "content": _cell(content),
+                    "buried_chapter": _cell(raw.get("buried_chapter") or raw.get("bury_chapter") or raw.get("埋设章") or ""),
+                    "payoff_chapter": _cell(raw.get("payoff_chapter") or raw.get("recover_chapter") or raw.get("回收章") or ""),
+                    "level": _cell(raw.get("level") or raw.get("层级") or default_level),
+                }
+            )
+    return items
+
+
+def _append_foreshadow_rows(text: str, items: list[dict[str, str]]) -> tuple[str, int]:
+    if not items:
+        return text, 0
+
+    lines = text.splitlines()
+    header_idx = next((i for i, line in enumerate(lines) if line.strip().startswith("| 伏笔内容")), None)
+    if header_idx is None:
+        lines.extend(
+            [
+                "",
+                "## 伏笔表",
+                "| 伏笔内容 | 埋设章 | 回收章 | 层级 |",
+                "|----------|--------|--------|------|",
+            ]
+        )
+        header_idx = len(lines) - 2
+
+    row_start = header_idx + 2
+    row_end = row_start
+    while row_end < len(lines) and lines[row_end].strip().startswith("|"):
+        row_end += 1
+
+    existing_contents = set()
+    blank_row_indices: list[int] = []
+    for idx in range(row_start, row_end):
+        cells = _split_row(lines[idx])
+        if cells and any(cell for cell in cells):
+            existing_contents.add(cells[0])
+        else:
+            blank_row_indices.append(idx)
+
+    for idx in reversed(blank_row_indices):
+        del lines[idx]
+        row_end -= 1
+
+    appended = 0
+    insert_at = row_end
+    for item in items:
+        if item["content"] in existing_contents:
+            continue
+        lines.insert(
+            insert_at,
+            _render_row([item["content"], item["buried_chapter"], item["payoff_chapter"], item["level"]]),
+        )
+        insert_at += 1
+        appended += 1
+        existing_contents.add(item["content"])
+
+    return "\n".join(lines).rstrip() + "\n", appended
+
+
+def sync_master_outline(
+    project_root: str | Path,
+    volume: int,
+    *,
+    writeback_file: str | Path | None = None,
+) -> dict[str, Any]:
+    root = Path(project_root).expanduser().resolve()
+    if volume < 1:
+        raise MasterOutlineSyncError("volume must be >= 1")
+
+    _require_current_volume_artifacts(root, volume)
+
+    outline_dir = root / "大纲"
+    master_path = outline_dir / "总纲.md"
+    if not master_path.is_file():
+        raise MasterOutlineSyncError("missing master outline: 大纲/总纲.md")
+
+    source_path = _resolve_writeback_source(root, outline_dir, volume, writeback_file)
+    payload = _read_json(source_path)
+    anchor = _normalize_anchor(payload, volume + 1)
+    structured_items = _structured_writeback_items(payload)
+
+    before = master_path.read_text(encoding="utf-8")
+    after, volume_changed = _update_volume_table(before, anchor)
+    after, appended_count = _append_foreshadow_rows(after, structured_items)
+    if after != before:
+        master_path.write_text(after, encoding="utf-8")
+
+    return {
+        "ok": True,
+        "master_outline": master_path.relative_to(root).as_posix(),
+        "writeback_file": source_path.relative_to(root).as_posix(),
+        "next_volume": volume + 1,
+        "volume_anchor_written": volume_changed,
+        "structured_items_appended": appended_count,
+        "updated": after != before,
+    }
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Sync minimal next-volume anchors into 大纲/总纲.md")
+    parser.add_argument("--project-root", required=True)
+    parser.add_argument("--volume", type=int, required=True, help="当前已完成规划的卷号")
+    parser.add_argument("--writeback-file", default="", help="显式结构化写回 JSON;默认 大纲/第N卷-总纲写回.json")
+    parser.add_argument("--format", choices=["json", "text"], default="json")
+    args = parser.parse_args()
+
+    try:
+        result = sync_master_outline(
+            args.project_root,
+            args.volume,
+            writeback_file=args.writeback_file or None,
+        )
+    except MasterOutlineSyncError as exc:
+        if args.format == "json":
+            print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2))
+        else:
+            print(f"ERROR {exc}", file=sys.stderr)
+        raise SystemExit(1)
+
+    if args.format == "json":
+        print(json.dumps(result, ensure_ascii=False, indent=2))
+    else:
+        print(
+            "OK master outline synced: "
+            f"next_volume={result['next_volume']} "
+            f"structured_items_appended={result['structured_items_appended']}"
+        )
+
+
+if __name__ == "__main__":
+    enable_windows_utf8_stdio(skip_in_pytest=True)
+    main()

+ 0 - 1
webnovel-writer/templates/output/大纲-总纲.md

@@ -31,7 +31,6 @@
 - 融合机制(一句话):
 - 节奏占比(建议 7:3):
 - 关键冲突与爽点:
-- 详见:设定集/复合题材-融合逻辑.md
 
 ## 卷划分
 | 卷号 | 卷名 | 章节范围 | 核心冲突 | 卷末高潮 |