Ver código fonte

fix: harden webnovel runtime reliability

lingfengQAQ 1 mês atrás
pai
commit
4b3c423019

+ 12 - 0
webnovel-writer/scripts/data_modules/index_entity_mixin.py

@@ -42,6 +42,18 @@ class IndexEntityMixin:
             self._register_alias_with_cursor(
             self._register_alias_with_cursor(
                 cursor, canonical_name, entity.id, entity.type
                 cursor, canonical_name, entity.id, entity.type
             )
             )
+        if entity.is_protagonist:
+            for alias in self._protagonist_aliases(entity, canonical_name):
+                self._register_alias_with_cursor(cursor, alias, entity.id, entity.type)
+
+    def _protagonist_aliases(self, entity: EntityMeta, canonical_name: str) -> List[str]:
+        aliases = ["protagonist", "主角"]
+        compact_id = str(entity.id or "").replace("_", "").replace("-", "").strip()
+        if compact_id and compact_id != entity.id:
+            aliases.append(compact_id)
+        if canonical_name:
+            aliases.append(canonical_name)
+        return list(dict.fromkeys(alias for alias in aliases if alias and alias != entity.id))
 
 
     def upsert_entity(self, entity: EntityMeta, update_metadata: bool = False) -> bool:
     def upsert_entity(self, entity: EntityMeta, update_metadata: bool = False) -> bool:
         """
         """

+ 6 - 1
webnovel-writer/scripts/data_modules/index_manager.py

@@ -664,6 +664,11 @@ class IndexManager(IndexChapterMixin, IndexEntityMixin, IndexDebtMixin, IndexRea
             current[field] = delta.get("new")
             current[field] = delta.get("new")
 
 
         canonical_name = str(delta.get("canonical_name") or delta.get("name") or entity_id).strip()
         canonical_name = str(delta.get("canonical_name") or delta.get("name") or entity_id).strip()
+        is_protagonist = bool(delta.get("is_protagonist"))
+        if "is_protagonist" not in delta:
+            existing = self.get_entity(entity_id)
+            if existing:
+                is_protagonist = bool(existing.get("is_protagonist"))
         entity = EntityMeta(
         entity = EntityMeta(
             id=entity_id,
             id=entity_id,
             type=str(delta.get("type") or "角色").strip() or "角色",
             type=str(delta.get("type") or "角色").strip() or "角色",
@@ -673,7 +678,7 @@ class IndexManager(IndexChapterMixin, IndexEntityMixin, IndexDebtMixin, IndexRea
             current=current,
             current=current,
             first_appearance=chapter,
             first_appearance=chapter,
             last_appearance=chapter,
             last_appearance=chapter,
-            is_protagonist=bool(delta.get("is_protagonist")),
+            is_protagonist=is_protagonist,
             is_archived=bool(delta.get("is_archived")),
             is_archived=bool(delta.get("is_archived")),
         )
         )
         self.upsert_entity(entity, update_metadata=True)
         self.upsert_entity(entity, update_metadata=True)

+ 54 - 0
webnovel-writer/scripts/data_modules/state_projection_writer.py

@@ -2,10 +2,17 @@
 # -*- coding: utf-8 -*-
 # -*- coding: utf-8 -*-
 from __future__ import annotations
 from __future__ import annotations
 
 
+import re
+from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
 from .story_contracts import read_json_if_exists, write_json
 from .story_contracts import read_json_if_exists, write_json
 
 
+try:
+    from chapter_paths import find_chapter_file
+except ImportError:  # pragma: no cover
+    from scripts.chapter_paths import find_chapter_file
+
 
 
 class StateProjectionWriter:
 class StateProjectionWriter:
     def __init__(self, project_root: Path):
     def __init__(self, project_root: Path):
@@ -44,7 +51,25 @@ class StateProjectionWriter:
             applied_count += 1
             applied_count += 1
 
 
         if chapter > 0:
         if chapter > 0:
+            old_current = self._safe_int(progress.get("current_chapter"))
+            old_total = self._safe_int(progress.get("total_words"))
+            old_status = chapter_status.get(str(chapter))
+
             chapter_status[str(chapter)] = "chapter_committed"
             chapter_status[str(chapter)] = "chapter_committed"
+            progress["current_chapter"] = max(old_current, chapter)
+
+            projected_total = self._project_total_words(chapter_status)
+            if projected_total > 0:
+                progress["total_words"] = projected_total
+            else:
+                progress["total_words"] = old_total
+
+            if (
+                old_status != "chapter_committed"
+                or progress.get("current_chapter") != old_current
+                or progress.get("total_words") != old_total
+            ):
+                progress["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
 
 
         write_json(state_path, state)
         write_json(state_path, state)
         return {
         return {
@@ -94,3 +119,32 @@ class StateProjectionWriter:
                 }
                 }
             )
             )
         return deltas
         return deltas
+
+    def _project_total_words(self, chapter_status: dict) -> int:
+        total = 0
+        for raw_chapter, raw_status in chapter_status.items():
+            if raw_status != "chapter_committed":
+                continue
+            chapter = self._safe_int(raw_chapter)
+            if chapter <= 0:
+                continue
+            chapter_file = find_chapter_file(self.project_root, chapter)
+            if chapter_file is None:
+                continue
+            try:
+                total += self._count_chapter_words(chapter_file.read_text(encoding="utf-8"))
+            except OSError:
+                continue
+        return total
+
+    def _count_chapter_words(self, content: str) -> int:
+        text = re.sub(r"```[\s\S]*?```", "", content)
+        text = re.sub(r"^#+ .*$", "", text, flags=re.MULTILINE)
+        text = re.sub(r"---", "", text)
+        return len(text.strip())
+
+    def _safe_int(self, value: object) -> int:
+        try:
+            return int(value or 0)
+        except (TypeError, ValueError):
+            return 0

+ 15 - 0
webnovel-writer/scripts/data_modules/tests/test_project_locator.py

@@ -95,6 +95,21 @@ def test_resolve_project_root_uses_workspace_pointer(tmp_path):
     assert resolved == project_root.resolve()
     assert resolved == project_root.resolve()
 
 
 
 
+def test_resolve_project_root_explicit_workspace_uses_unique_child_project(tmp_path):
+    _ensure_scripts_on_path()
+
+    from project_locator import resolve_project_root
+
+    workspace = tmp_path / "workspace"
+    (workspace / ".git").mkdir(parents=True, exist_ok=True)
+    project_root = workspace / "凡人资本论"
+    (project_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (project_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+
+    resolved = resolve_project_root(str(workspace))
+    assert resolved == project_root.resolve()
+
+
 def test_resolve_project_root_ignores_stale_pointer_and_fallbacks(tmp_path):
 def test_resolve_project_root_ignores_stale_pointer_and_fallbacks(tmp_path):
     _ensure_scripts_on_path()
     _ensure_scripts_on_path()
 
 

+ 126 - 0
webnovel-writer/scripts/data_modules/tests/test_projection_writers.py

@@ -37,6 +37,71 @@ def test_state_projection_writer_applies_accepted_commit(tmp_path):
     payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
     payload = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
     assert payload["entity_state"]["x"]["realm"] == "斗者"
     assert payload["entity_state"]["x"]["realm"] == "斗者"
     assert payload["progress"]["chapter_status"]["3"] == "chapter_committed"
     assert payload["progress"]["chapter_status"]["3"] == "chapter_committed"
+    assert payload["progress"]["current_chapter"] == 3
+    assert payload["progress"]["last_updated"]
+
+
+def test_accepted_chapter_commits_advance_progress_and_word_count(tmp_path):
+    (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (tmp_path / ".webnovel" / "state.json").write_text(
+        json.dumps(
+            {"progress": {"current_chapter": 0, "total_words": 0, "last_updated": "2026-01-01 00:00:00"}},
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    chapters_dir = tmp_path / "正文"
+    chapters_dir.mkdir(parents=True, exist_ok=True)
+    (chapters_dir / "第0001章.md").write_text("第一章正文内容", encoding="utf-8")
+    (chapters_dir / "第0002章.md").write_text("第二章正文内容更多", encoding="utf-8")
+
+    service = ChapterCommitService(tmp_path)
+    for chapter in (1, 2):
+        payload = service.build_commit(
+            chapter=chapter,
+            review_result={"blocking_count": 0},
+            fulfillment_result={"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
+            disambiguation_result={"pending": []},
+            extraction_result={"state_deltas": [], "entity_deltas": [], "accepted_events": []},
+        )
+        service.apply_projections(payload)
+
+    state = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
+    assert state["progress"]["chapter_status"]["1"] == "chapter_committed"
+    assert state["progress"]["chapter_status"]["2"] == "chapter_committed"
+    assert state["progress"]["current_chapter"] == 2
+    assert state["progress"]["total_words"] > 0
+    assert state["progress"]["last_updated"] != "2026-01-01 00:00:00"
+
+
+def test_reapplying_accepted_chapter_commit_does_not_double_count_words(tmp_path):
+    (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (tmp_path / ".webnovel" / "state.json").write_text(
+        json.dumps(
+            {"progress": {"current_chapter": 0, "total_words": 0}},
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    chapters_dir = tmp_path / "正文"
+    chapters_dir.mkdir(parents=True, exist_ok=True)
+    (chapters_dir / "第0001章.md").write_text("第一章正文内容", encoding="utf-8")
+
+    payload = {
+        "meta": {"status": "accepted", "chapter": 1},
+        "state_deltas": [],
+        "accepted_events": [],
+    }
+    writer = StateProjectionWriter(tmp_path)
+    writer.apply(payload)
+    first_state = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
+
+    writer.apply(payload)
+    second_state = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
+
+    assert second_state["progress"]["current_chapter"] == 1
+    assert second_state["progress"]["total_words"] == first_state["progress"]["total_words"]
+    assert second_state["progress"]["last_updated"] == first_state["progress"]["last_updated"]
 
 
 
 
 def test_state_projection_writer_derives_delta_from_power_breakthrough_event(tmp_path):
 def test_state_projection_writer_derives_delta_from_power_breakthrough_event(tmp_path):
@@ -108,6 +173,67 @@ def test_index_projection_writer_applies_entity_delta(tmp_path):
     assert entity["current_json"]["realm"] == "斗者"
     assert entity["current_json"]["realm"] == "斗者"
 
 
 
 
+def test_index_projection_writer_registers_stable_protagonist_aliases(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    writer = IndexProjectionWriter(tmp_path)
+
+    result = writer.apply(
+        {
+            "meta": {"status": "accepted", "chapter": 1},
+            "entity_deltas": [
+                {
+                    "entity_id": "lu_ming",
+                    "canonical_name": "陆鸣",
+                    "type": "角色",
+                    "tier": "核心",
+                    "chapter": 1,
+                    "is_protagonist": True,
+                }
+            ],
+        }
+    )
+
+    manager = IndexManager(cfg)
+    assert result["applied"] is True
+    assert manager.get_entity("lu_ming")["canonical_name"] == "陆鸣"
+    assert manager.get_entity("陆鸣")["id"] == "lu_ming"
+    assert manager.get_entity("protagonist")["id"] == "lu_ming"
+    assert manager.get_entity("luming")["id"] == "lu_ming"
+
+
+def test_entity_delta_without_protagonist_flag_preserves_existing_protagonist(tmp_path):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    manager = IndexManager(cfg)
+    manager.apply_entity_delta(
+        {
+            "entity_id": "lu_ming",
+            "canonical_name": "陆鸣",
+            "type": "角色",
+            "tier": "核心",
+            "chapter": 1,
+            "is_protagonist": True,
+        }
+    )
+
+    manager.apply_entity_delta(
+        {
+            "entity_id": "lu_ming",
+            "canonical_name": "陆鸣",
+            "type": "角色",
+            "tier": "核心",
+            "chapter": 2,
+            "field": "realm",
+            "new": "炼气二层",
+        }
+    )
+
+    assert manager.get_protagonist()["id"] == "lu_ming"
+    assert manager.get_entity("protagonist")["id"] == "lu_ming"
+    assert manager.get_entity("lu_ming")["is_protagonist"] == 1
+
+
 def test_index_projection_writer_derives_relationship_from_event(tmp_path):
 def test_index_projection_writer_derives_relationship_from_event(tmp_path):
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()
     cfg.ensure_dirs()

+ 7 - 0
webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py

@@ -295,6 +295,13 @@ def test_webnovel_write_skill_uses_chapter_commit_as_step5_mainline():
     assert "state process-chapter" not in text
     assert "state process-chapter" not in text
 
 
 
 
+def test_webnovel_write_skill_uses_project_root_backup_not_bare_git_add():
+    text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
+    assert "webnovel.py" in text
+    assert "--project-root \"${PROJECT_ROOT}\" backup" in text
+    assert "git add ." not in text
+
+
 def test_webnovel_query_skill_prefers_story_system_and_memory_contract():
 def test_webnovel_query_skill_prefers_story_system_and_memory_contract():
     text = (SKILLS_DIR / "webnovel-query" / "SKILL.md").read_text(encoding="utf-8")
     text = (SKILLS_DIR / "webnovel-query" / "SKILL.md").read_text(encoding="utf-8")
     assert "memory-contract load-context" in text
     assert "memory-contract load-context" in text

+ 92 - 0
webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py

@@ -95,6 +95,54 @@ def test_extract_context_forwards_with_resolved_project_root(monkeypatch, tmp_pa
     ]
     ]
 
 
 
 
+def test_backup_forwards_resolved_book_root_from_parent_workspace(monkeypatch, tmp_path):
+    module = _load_webnovel_module()
+
+    workspace_root = (tmp_path / "workspace").resolve()
+    book_root = (workspace_root / "book").resolve()
+    (workspace_root / ".git").mkdir(parents=True, exist_ok=True)
+    (book_root / ".git").mkdir(parents=True, exist_ok=True)
+    (book_root / ".webnovel").mkdir(parents=True, exist_ok=True)
+    (book_root / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
+    called = {}
+
+    def _fake_run_script(script_name, argv):
+        called["script_name"] = script_name
+        called["argv"] = list(argv)
+        return 0
+
+    monkeypatch.chdir(workspace_root)
+    monkeypatch.setattr(module, "_run_script", _fake_run_script)
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "webnovel",
+            "--project-root",
+            str(workspace_root),
+            "backup",
+            "--chapter",
+            "2",
+            "--chapter-title",
+            "第二章",
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    assert int(exc.value.code or 0) == 0
+    assert called["script_name"] == "backup_manager.py"
+    assert called["argv"] == [
+        "--project-root",
+        str(book_root),
+        "--chapter",
+        "2",
+        "--chapter-title",
+        "第二章",
+    ]
+
+
 def test_webnovel_story_system_forwards_with_resolved_project_root(monkeypatch, tmp_path):
 def test_webnovel_story_system_forwards_with_resolved_project_root(monkeypatch, tmp_path):
     module = _load_webnovel_module()
     module = _load_webnovel_module()
 
 
@@ -277,6 +325,50 @@ def test_preflight_includes_story_runtime_health(monkeypatch, tmp_path, capsys):
     assert '"mainline_ready"' in captured.out
     assert '"mainline_ready"' in captured.out
 
 
 
 
+def test_where_reports_empty_workspace_without_traceback(monkeypatch, tmp_path, capsys):
+    module = _load_webnovel_module()
+    workspace = tmp_path / "workspace"
+    workspace.mkdir(parents=True, exist_ok=True)
+    (workspace / ".git").mkdir(parents=True, exist_ok=True)
+
+    monkeypatch.chdir(workspace)
+    monkeypatch.delenv("WEBNOVEL_PROJECT_ROOT", raising=False)
+    monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
+    monkeypatch.setenv("WEBNOVEL_CLAUDE_HOME", str(tmp_path / "empty-claude-home"))
+    monkeypatch.setattr(sys, "argv", ["webnovel", "where"])
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    captured = capsys.readouterr()
+    assert int(exc.value.code or 0) == 1
+    assert "还没有激活的书项目" in captured.err
+    assert "Traceback" not in captured.err
+
+
+def test_preflight_reports_empty_workspace_without_traceback(monkeypatch, tmp_path, capsys):
+    module = _load_webnovel_module()
+    workspace = tmp_path / "workspace"
+    workspace.mkdir(parents=True, exist_ok=True)
+    (workspace / ".git").mkdir(parents=True, exist_ok=True)
+
+    monkeypatch.chdir(workspace)
+    monkeypatch.delenv("WEBNOVEL_PROJECT_ROOT", raising=False)
+    monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False)
+    monkeypatch.setenv("WEBNOVEL_CLAUDE_HOME", str(tmp_path / "empty-claude-home"))
+    monkeypatch.setattr(sys, "argv", ["webnovel", "preflight", "--format", "json"])
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    captured = capsys.readouterr()
+    report = json.loads(captured.out)
+    assert int(exc.value.code or 0) == 1
+    assert report["ok"] is False
+    assert "还没有激活的书项目" in report["project_root_error"]
+    assert "Traceback" not in captured.err
+
+
 def test_quality_trend_report_writes_to_book_root_when_input_is_workspace_root(tmp_path, monkeypatch):
 def test_quality_trend_report_writes_to_book_root_when_input_is_workspace_root(tmp_path, monkeypatch):
     _ensure_scripts_on_path()
     _ensure_scripts_on_path()
     import quality_trend_report as quality_trend_report_module
     import quality_trend_report as quality_trend_report_module

+ 74 - 3
webnovel-writer/scripts/data_modules/webnovel.py

@@ -68,6 +68,42 @@ def _strip_project_root_args(argv: list[str]) -> list[str]:
     return out
     return out
 
 
 
 
+PASSTHROUGH_TOOLS = {
+    "index",
+    "state",
+    "rag",
+    "style",
+    "entity",
+    "context",
+    "memory",
+    "migrate",
+    "status",
+    "update-state",
+    "backup",
+    "archive",
+    "init",
+    "story-system",
+    "memory-contract",
+    "project-memory",
+}
+
+
+def _passthrough_tail(argv: list[str], tool: str) -> list[str]:
+    i = 0
+    while i < len(argv):
+        token = argv[i]
+        if token == "--project-root":
+            i += 2
+            continue
+        if token.startswith("--project-root="):
+            i += 1
+            continue
+        if token == tool:
+            return list(argv[i + 1 :])
+        i += 1
+    return []
+
+
 def _run_data_module(module: str, argv: list[str]) -> int:
 def _run_data_module(module: str, argv: list[str]) -> int:
     """
     """
     Import `data_modules.<module>` and call its main(), while isolating sys.argv.
     Import `data_modules.<module>` and call its main(), while isolating sys.argv.
@@ -103,11 +139,31 @@ def _run_script(script_name: str, argv: list[str]) -> int:
 
 
 
 
 def cmd_where(args: argparse.Namespace) -> int:
 def cmd_where(args: argparse.Namespace) -> int:
-    root = _resolve_root(args.project_root)
+    try:
+        root = _resolve_root(args.project_root)
+    except FileNotFoundError as exc:
+        print(_project_root_diagnostic(args.project_root, exc), file=sys.stderr)
+        return 1
     print(str(root))
     print(str(root))
     return 0
     return 0
 
 
 
 
+def _project_root_diagnostic(
+    explicit_project_root: Optional[str], exc: FileNotFoundError
+) -> str:
+    if explicit_project_root:
+        return (
+            "未找到有效书项目根目录(需要包含 .webnovel/state.json): "
+            f"{explicit_project_root}\n"
+            f"detail: {exc}"
+        )
+    return (
+        "当前工作区还没有激活的书项目(未找到 .webnovel/state.json)。\n"
+        "请先运行 webnovel init 创建项目,或运行 webnovel use <project_root> 绑定已有书项目。\n"
+        f"detail: {exc}"
+    )
+
+
 def _build_preflight_report(explicit_project_root: Optional[str]) -> dict:
 def _build_preflight_report(explicit_project_root: Optional[str]) -> dict:
     scripts_dir = _scripts_dir().resolve()
     scripts_dir = _scripts_dir().resolve()
     plugin_root = scripts_dir.parent
     plugin_root = scripts_dir.parent
@@ -130,6 +186,16 @@ def _build_preflight_report(explicit_project_root: Optional[str]) -> dict:
         project_root = str(resolved_root)
         project_root = str(resolved_root)
         checks.append({"name": "project_root", "ok": True, "path": project_root})
         checks.append({"name": "project_root", "ok": True, "path": project_root})
         story_runtime = build_story_runtime_health(resolved_root)
         story_runtime = build_story_runtime_health(resolved_root)
+    except FileNotFoundError as exc:
+        project_root_error = _project_root_diagnostic(explicit_project_root, exc)
+        checks.append(
+            {
+                "name": "project_root",
+                "ok": False,
+                "path": explicit_project_root or "",
+                "error": project_root_error,
+            }
+        )
     except Exception as exc:
     except Exception as exc:
         project_root_error = str(exc)
         project_root_error = str(exc)
         checks.append({"name": "project_root", "ok": False, "path": explicit_project_root or "", "error": project_root_error})
         checks.append({"name": "project_root", "ok": False, "path": explicit_project_root or "", "error": project_root_error})
@@ -309,15 +375,20 @@ def main() -> None:
     from .cli_args import normalize_global_project_root
     from .cli_args import normalize_global_project_root
 
 
     argv = normalize_global_project_root(sys.argv[1:])
     argv = normalize_global_project_root(sys.argv[1:])
-    args = parser.parse_args(argv)
+    args, unknown_args = parser.parse_known_args(argv)
 
 
     # where/use 直接执行
     # where/use 直接执行
     if hasattr(args, "func"):
     if hasattr(args, "func"):
+        if unknown_args:
+            parser.error(f"unrecognized arguments: {' '.join(unknown_args)}")
         code = int(args.func(args) or 0)
         code = int(args.func(args) or 0)
         raise SystemExit(code)
         raise SystemExit(code)
 
 
     tool = args.tool
     tool = args.tool
-    rest = list(getattr(args, "args", []) or [])
+    if unknown_args and tool not in PASSTHROUGH_TOOLS:
+        parser.error(f"unrecognized arguments: {' '.join(unknown_args)}")
+
+    rest = _passthrough_tail(argv, tool) if tool in PASSTHROUGH_TOOLS else list(getattr(args, "args", []) or [])
     # argparse.REMAINDER 可能以 `--` 开头占位,这里去掉
     # argparse.REMAINDER 可能以 `--` 开头占位,这里去掉
     if rest[:1] == ["--"]:
     if rest[:1] == ["--"]:
         rest = rest[1:]
         rest = rest[1:]

+ 20 - 0
webnovel-writer/scripts/project_locator.py

@@ -283,6 +283,22 @@ def _resolve_project_root_from_pointer(cwd: Path, *, stop_at: Optional[Path] = N
     return None
     return None
 
 
 
 
+def _resolve_unique_child_project_root(root: Path) -> Optional[Path]:
+    """
+    Resolve a workspace root that contains exactly one direct child book project.
+
+    This supports commands invoked with a parent workspace path while keeping
+    ambiguous multi-book workspaces explicit.
+    """
+    try:
+        children = [child.resolve() for child in root.iterdir() if child.is_dir() and _is_project_root(child)]
+    except OSError:
+        return None
+    if len(children) == 1:
+        return children[0]
+    return None
+
+
 def _find_workspace_root_with_claude(start: Path) -> Optional[Path]:
 def _find_workspace_root_with_claude(start: Path) -> Optional[Path]:
     """Find nearest ancestor containing `.claude/`."""
     """Find nearest ancestor containing `.claude/`."""
     for candidate in (start, *start.parents):
     for candidate in (start, *start.parents):
@@ -357,6 +373,10 @@ def resolve_project_root(explicit_project_root: Optional[str] = None, *, cwd: Op
         if pointer_root is not None:
         if pointer_root is not None:
             return pointer_root
             return pointer_root
 
 
+        child_root = _resolve_unique_child_project_root(root)
+        if child_root is not None:
+            return child_root
+
         # 兼容:显式传入“工作区根目录”但其 `.claude/` 在用户目录(全局安装)时,
         # 兼容:显式传入“工作区根目录”但其 `.claude/` 在用户目录(全局安装)时,
         # workspace 内部可能没有指针文件。此时从用户级 registry 查找。
         # workspace 内部可能没有指针文件。此时从用户级 registry 查找。
         reg_root = _resolve_project_root_from_global_registry(
         reg_root = _resolve_project_root_from_global_registry(

+ 5 - 2
webnovel-writer/skills/webnovel-write/SKILL.md

@@ -154,10 +154,13 @@ commit 未生成→重跑 5.2。projection 失败→只补跑失败项。不回
 ### Step 6:Git 备份
 ### Step 6:Git 备份
 
 
 ```bash
 ```bash
-git add .
-git -c i18n.commitEncoding=UTF-8 commit -m "第{chapter_num}章: {title}"
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" backup \
+  --chapter {chapter_num} \
+  --chapter-title "{title}"
 ```
 ```
 
 
+备份必须以解析后的 `PROJECT_ROOT` 为准,禁止从工作区父目录执行裸全量 Git add,避免把书项目仓库作为父仓库的嵌入仓库/submodule 加入。
+
 ## 充分性闸门
 ## 充分性闸门
 
 
 1. 正文文件存在且非空
 1. 正文文件存在且非空