Переглянути джерело

feat: complete author-friendly reporting layer

lingfengQAQ 2 тижнів тому
батько
коміт
711b1471dc

+ 13 - 0
README.md

@@ -161,6 +161,19 @@ Dashboard 是个只读面板,能看项目状态、实体关系图、章节内
 
 这么设计,是为了把“怎么写”和“写了什么”分开:文笔和节奏可以放开发挥,但发生过的事实必须登记、过审、存档,不能含糊。
 
+### 最终报告怎么看
+
+`/webnovel-init`、`/webnovel-plan`、`/webnovel-write` 和 `/webnovel-review` 结束时都会给一份面向作者的最终报告,不直接把内部 JSON、traceback 或长命令日志甩出来。报告先给一句总状态:
+
+- **已完成**:目标产物和关键校验都通过,可以进入下一步。
+- **部分完成**:主要产物已保留,但有跳过项、自动处理项或待确认的小尾巴。
+- **需要你处理**:系统已经停在安全位置,需要你决定创作方向、事实取舍、是否覆盖文件或如何处理 blocking 问题。
+- **未完成**:关键产物没有可信生成,按报告里的恢复建议重跑或排查。
+
+下面固定三段:一是产生的文件与完成情况,二是过程中遇到的问题与异常耗时,三是下一步建议。系统自动处理过的事也会写出来,比如投影失败后已补跑成功;只有不可恢复故障才会提示查看 `.webnovel/logs/run_last.log`。
+
+执行过程中只会看到少量进度提示,告诉你当前在做什么、会产生什么;只有创作方向、事实一致性、文件覆盖风险或 blocking issue 需要裁决时才会问你。重复执行同一条 `/webnovel-write 章号` 时,系统会先检查可信断点,尽量从失败点继续,不重写已经可信完成的正文、审查、提交或备份。
+
 ## 内置题材
 
 内置 37 个中文网文题材模板,也支持把几个题材揉在一起写。下面只列一部分:

+ 28 - 0
docs/guides/commands.md

@@ -95,6 +95,19 @@
 python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" <子命令> [参数]
 ```
 
+### 作者友好运行体验
+
+`/webnovel-init`、`/webnovel-plan`、`/webnovel-write` 和 `/webnovel-review` 结束时都会输出统一最终报告。报告不直接输出原始 JSON、traceback 或长命令日志,而是先给一句总状态,再分三段说明:产生的文件与完成情况、过程中遇到的问题与异常耗时、下一步建议。
+
+总状态有四种:
+
+- **已完成**:目标产物和关键校验都通过。
+- **部分完成**:主要产物已保留,但存在跳过项、自动处理项或待确认事项。
+- **需要你处理**:系统停在安全位置,需要作者裁决创作方向、事实取舍、文件覆盖或 blocking 问题。
+- **未完成**:关键产物没有可信生成,需要按报告建议重跑或排查。
+
+长流程执行中只显示少量过程提示,说明当前阶段和会产生什么。自动补跑投影、重新 emit 缺失合同这类幂等操作不会打断作者,但会出现在最终报告里。重复执行同一条主命令时,系统会优先检查可信断点;首版断点续跑重点覆盖 `/webnovel-write`,尽量从失败点继续,而不是重写已可信完成的正文、审查、提交或备份。
+
 ## Story System 主链
 
 推荐按以下顺序执行:
@@ -134,8 +147,19 @@ python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJE
 | `doctor` | 阶段感知项目体检(目录、文件、DB、RAG、依赖、Dashboard) |
 | `write-gate` | 写章自然边界校验(`prewrite` / `precommit` / `postcommit`) |
 | `projections` | 从已有 commit 补跑或重放 projection |
+| `user-report` | 渲染作者友好的最终报告,可输出 text/json |
+| `run-ledger` | 记录写章步骤状态,或生成 `/webnovel-write` 断点续跑建议 |
+| `run-log` | 写入脱敏运行日志 `.webnovel/logs/run_last.log` |
 | `use <路径>` | 绑定当前工作区使用的书项目 |
 
+示例:
+
+```bash
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" user-report --stage write --chapter 12 --format text
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" run-ledger write-resume --chapter 12 --format text
+python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJECT_ROOT>" run-log --event write_failed --payload-json "{\"chapter\":12,\"reason\":\"projection timeout\"}"
+```
+
 ### 数据模块子命令
 
 | 子命令 | 说明 |
@@ -188,6 +212,10 @@ python -X utf8 "<CLAUDE_PLUGIN_ROOT>/scripts/webnovel.py" --project-root "<PROJE
 | `write-gate --chapter N --stage postcommit` | 提交后检查 commit 与 projection 状态 |
 | `projections retry --chapter N` | 基于已有 commit 补跑单章 projection |
 | `projections replay --from-chapter A --to-chapter B` | 按章节范围重放 projection |
+| `user-report --stage write --chapter N` | 汇总本次写章产物、问题和下一步建议 |
+| `run-ledger record-write-step --chapter N` | 记录写章关键步骤的状态、输入输出、问题和耗时 |
+| `run-ledger write-resume --chapter N` | 根据可信断点输出续跑建议,不自动覆盖文件 |
+| `run-log --event <name>` | 写入脱敏日志,供不可恢复故障排查 |
 | `story-events --chapter N` | 查询指定章节事件 |
 | `story-events --health` | 事件链健康检查 |
 | `memory-contract` | 记忆合同管理 |

+ 26 - 0
docs/operations/operations.md

@@ -142,6 +142,32 @@ python -X utf8 "${CLAUDE_PLUGIN_ROOT}/scripts/webnovel.py" --project-root "${PRO
 
 投影补跑只从已有 `.story-system/commits/*.commit.json` 读取事实,并重新生成 `.webnovel/state.json`、`index.db`、`summaries/`、`memory_scratchpad.json`、`vectors.db` 等 read-model。每次执行会追加 `.webnovel/projection_log.jsonl`。
 
+### 作者友好报告与恢复
+
+主 Skill 的最终报告统一使用四种总状态:已完成、部分完成、需要你处理、未完成。报告只给作者需要知道的结论、产物、问题和下一步建议;内部 JSON、traceback 和长命令日志不直接展示。
+
+异常分三类处理:
+
+- **已自动处理**:幂等、可重试、不碰作者内容的问题,例如 projection retry 成功、缺失 runtime contract 后重新生成。流程默认继续,但最终报告必须说明处理过什么。
+- **需要确认**:会影响创作方向、事实取舍、是否覆盖文件或断点续跑边界的问题,例如正文被手动改过、章纲更新晚于正文、本章已 accepted 后再次写章。系统应给 2-3 个有限选项。
+- **必须处理**:blocking 审查问题、关键产物缺失、commit 被拒、投影补跑仍失败等。系统停在安全位置,报告说明已完成内容、卡点和恢复建议。
+
+`/webnovel-write` 会记录写章断点,用于重跑时判断可信完成项:
+
+```bash
+python -X utf8 "${CLAUDE_PLUGIN_ROOT}/scripts/webnovel.py" --project-root "${PROJECT_ROOT}" run-ledger write-resume --chapter 12 --format text
+```
+
+断点建议只负责判断和提示,不自动覆盖文件。凡是涉及作者手改正文、旧正文是否沿用、accepted commit 是否重做,都必须先询问。
+
+不可恢复故障会提示查看:
+
+```text
+.webnovel/logs/run_last.log
+```
+
+该日志用于保留最近一次运行的脱敏技术细节,便于排查。写入日志时会遮蔽常见敏感字段和值,包括 `api_key`、`secret`、`token`、`authorization`、`password`、`passwd`、`credential` 以及形如 `KEY=value` 的内联密钥片段。日志仍可能包含文件路径和错误上下文,提交 issue 前建议再人工扫一眼。
+
 ### 测试
 
 ```bash

+ 24 - 0
webnovel-writer/evals/fixtures/behavior/fast.json

@@ -130,6 +130,30 @@
       "id": "dashboard_read_only",
       "type": "dashboard_read_only",
       "description": "Dashboard exposes GET-only API semantics."
+    },
+    {
+      "id": "user_report_minimal_review_skipped",
+      "type": "user_report_probe",
+      "scenario": "minimal_review_skipped",
+      "description": "/webnovel-write --minimal final report explains skipped reviewer instead of pretending full review passed."
+    },
+    {
+      "id": "user_report_missing_data_artifacts",
+      "type": "user_report_probe",
+      "scenario": "missing_data_artifacts",
+      "description": "Missing data-agent artifacts prevent a completed write report."
+    },
+    {
+      "id": "user_report_projection_retry_auto_handled",
+      "type": "user_report_probe",
+      "scenario": "projection_retry_auto_handled",
+      "description": "Projection retry success is reported as auto-handled."
+    },
+    {
+      "id": "user_report_review_blocking_must_handle",
+      "type": "user_report_probe",
+      "scenario": "review_blocking_must_handle",
+      "description": "Reviewer blocking issues are reported as must-handle."
     }
   ]
 }

+ 373 - 0
webnovel-writer/scripts/data_modules/run_ledger.py

@@ -0,0 +1,373 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+try:
+    from chapter_paths import find_chapter_file
+except ImportError:  # pragma: no cover
+    from scripts.chapter_paths import find_chapter_file
+
+from .artifact_validator import OK_PROJECTION_STATUSES, REQUIRED_PROJECTION_WRITERS
+from .project_phase import COMMIT_ARTIFACT_FILES, contract_files_for_chapter
+from .projection_log import latest_projection_run, projection_status_from_run
+
+
+SCHEMA_VERSION = "webnovel-run-ledger/v1"
+LEDGER_REL = Path(".webnovel") / "run_ledger.json"
+WRITE_STEPS = ("draft", "review", "data", "commit", "projection", "backup")
+
+
+def ledger_path(project_root: str | Path) -> Path:
+    return Path(project_root) / LEDGER_REL
+
+
+def _now_iso() -> str:
+    return datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+    try:
+        payload = json.loads(path.read_text(encoding="utf-8"))
+    except Exception:
+        return {}
+    return payload if isinstance(payload, dict) else {}
+
+
+def load_ledger(project_root: str | Path) -> dict[str, Any]:
+    payload = _read_json(ledger_path(project_root))
+    if payload.get("schema_version") != SCHEMA_VERSION:
+        return {"schema_version": SCHEMA_VERSION, "write": {}}
+    payload.setdefault("write", {})
+    if not isinstance(payload["write"], dict):
+        payload["write"] = {}
+    return payload
+
+
+def save_ledger(project_root: str | Path, ledger: dict[str, Any]) -> Path:
+    path = ledger_path(project_root)
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(ledger, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
+    return path
+
+
+def file_signature(path: str | Path) -> dict[str, Any]:
+    target = Path(path)
+    if not target.is_file():
+        return {"path": str(target), "exists": False}
+    stat = target.stat()
+    digest = hashlib.sha256(target.read_bytes()).hexdigest()
+    return {
+        "path": str(target),
+        "exists": True,
+        "size": stat.st_size,
+        "mtime_ns": stat.st_mtime_ns,
+        "sha256": digest,
+    }
+
+
+def _chapter_key(chapter: int) -> str:
+    return f"chapter_{int(chapter):03d}"
+
+
+def _write_run(ledger: dict[str, Any], chapter: int, mode: str) -> dict[str, Any]:
+    write = ledger.setdefault("write", {})
+    key = _chapter_key(chapter)
+    run = write.setdefault(key, {})
+    run.setdefault("chapter", int(chapter))
+    run.setdefault("mode", mode or "default")
+    run.setdefault("steps", {})
+    run["updated_at"] = _now_iso()
+    return run
+
+
+def record_write_step(
+    project_root: str | Path,
+    *,
+    chapter: int,
+    step: str,
+    status: str,
+    mode: str = "default",
+    inputs: dict[str, str | Path] | None = None,
+    outputs: dict[str, str | Path] | None = None,
+    problems: list[str] | None = None,
+    auto_handled: list[str] | None = None,
+    duration_ms: int = 0,
+) -> dict[str, Any]:
+    if step not in WRITE_STEPS:
+        raise ValueError(f"unknown write step: {step}")
+    root = Path(project_root)
+    ledger = load_ledger(root)
+    run = _write_run(ledger, chapter, mode)
+    input_signatures = {
+        str(name): file_signature(path)
+        for name, path in (inputs or {}).items()
+    }
+    output_signatures = {
+        str(name): file_signature(path)
+        for name, path in (outputs or {}).items()
+    }
+    entry = {
+        "step": step,
+        "status": status,
+        "recorded_at": _now_iso(),
+        "duration_ms": int(duration_ms or 0),
+        "inputs": input_signatures,
+        "outputs": output_signatures,
+        "problems": list(problems or []),
+        "auto_handled": list(auto_handled or []),
+    }
+    run["steps"][step] = entry
+    save_ledger(root, ledger)
+    return entry
+
+
+def _same_signature(expected: dict[str, Any] | None, current: dict[str, Any]) -> bool:
+    if not isinstance(expected, dict):
+        return False
+    return bool(expected.get("exists")) and expected.get("sha256") == current.get("sha256")
+
+
+def _step_completed(run: dict[str, Any], step: str) -> dict[str, Any] | None:
+    steps = run.get("steps") if isinstance(run.get("steps"), dict) else {}
+    entry = steps.get(step)
+    if not isinstance(entry, dict):
+        return None
+    return entry if entry.get("status") == "completed" else None
+
+
+def _trusted_output(entry: dict[str, Any] | None, name: str) -> bool:
+    if not entry:
+        return False
+    outputs = entry.get("outputs") if isinstance(entry.get("outputs"), dict) else {}
+    expected = outputs.get(name)
+    if not isinstance(expected, dict):
+        return False
+    return _same_signature(expected, file_signature(expected.get("path") or ""))
+
+
+def _trusted_input(entry: dict[str, Any] | None, name: str, path: Path | None) -> bool:
+    if not entry or path is None:
+        return False
+    inputs = entry.get("inputs") if isinstance(entry.get("inputs"), dict) else {}
+    expected = inputs.get(name)
+    if not isinstance(expected, dict):
+        return False
+    return _same_signature(expected, file_signature(path))
+
+
+def _commit_path(project_root: Path, chapter: int) -> Path:
+    return project_root / ".story-system" / "commits" / f"chapter_{chapter:03d}.commit.json"
+
+
+def _commit_status(project_root: Path, chapter: int) -> str:
+    payload = _read_json(_commit_path(project_root, chapter))
+    meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
+    return str(meta.get("status") or "")
+
+
+def _projection_done(project_root: Path, chapter: int) -> bool:
+    run = latest_projection_run(project_root, chapter=chapter)
+    statuses = projection_status_from_run(run) if run else {}
+    if not statuses:
+        payload = _read_json(_commit_path(project_root, chapter))
+        raw = payload.get("projection_status") if isinstance(payload.get("projection_status"), dict) else {}
+        statuses = {str(key): str(value) for key, value in raw.items()}
+    if not statuses:
+        return False
+    return all(str(statuses.get(writer) or "") in OK_PROJECTION_STATUSES for writer in REQUIRED_PROJECTION_WRITERS)
+
+
+def _backup_exists(project_root: Path, chapter: int) -> bool:
+    backup_dir = project_root / ".webnovel" / "backups"
+    if not backup_dir.is_dir():
+        return False
+    return any(backup_dir.glob(f"ch{chapter:04d}*"))
+
+
+def _latest_contract_mtime(project_root: Path, chapter: int) -> int:
+    mtimes: list[int] = []
+    for path in contract_files_for_chapter(project_root, chapter).values():
+        if path.is_file():
+            mtimes.append(path.stat().st_mtime_ns)
+    return max(mtimes or [0])
+
+
+def build_write_resume_plan(
+    project_root: str | Path,
+    *,
+    chapter: int,
+    mode: str = "default",
+) -> dict[str, Any]:
+    root = Path(project_root)
+    ledger = load_ledger(root)
+    run = ((ledger.get("write") or {}).get(_chapter_key(chapter)) or {})
+    if not isinstance(run, dict):
+        run = {}
+
+    chapter_file = find_chapter_file(root, chapter)
+    draft_entry = _step_completed(run, "draft")
+    review_entry = _step_completed(run, "review")
+    data_entry = _step_completed(run, "data")
+    commit_status = _commit_status(root, chapter)
+    commit_done = commit_status in {"accepted", "rejected"}
+    accepted_done = commit_status == "accepted"
+
+    steps: list[dict[str, str]] = []
+    confirmations: list[dict[str, str]] = []
+
+    draft_trusted = bool(accepted_done or (chapter_file and _trusted_output(draft_entry, "chapter_file")))
+    if draft_entry and chapter_file and not draft_trusted:
+        confirmations.append(
+            {
+                "code": "chapter_file_changed",
+                "message": "正文文件与上次记录不一致,需要确认沿用手改正文还是重新起草。",
+            }
+        )
+    if draft_trusted and chapter_file and _latest_contract_mtime(root, chapter) > chapter_file.stat().st_mtime_ns:
+        draft_trusted = False
+        confirmations.append(
+            {
+                "code": "outline_newer_than_draft",
+                "message": "章纲或合同晚于正文,需要确认沿用旧正文还是重新起草。",
+            }
+        )
+    steps.append({"step": "draft", "action": "skip" if draft_trusted else "run", "reason": "正文可信" if draft_trusted else "正文缺失或已过期"})
+
+    review_path = root / COMMIT_ARTIFACT_FILES[0]
+    review_trusted = bool(accepted_done or (draft_trusted and review_path.is_file() and _trusted_input(review_entry, "chapter_file", chapter_file)))
+    steps.append({"step": "review", "action": "skip" if review_trusted else "run", "reason": "审查结果匹配当前正文" if review_trusted else "正文变更后需要重审"})
+
+    data_paths = [root / rel for rel in COMMIT_ARTIFACT_FILES[1:]]
+    data_trusted = bool(accepted_done or (review_trusted and all(path.is_file() for path in data_paths) and _trusted_input(data_entry, "chapter_file", chapter_file)))
+    steps.append({"step": "data", "action": "skip" if data_trusted else "run", "reason": "故事事实提取可信" if data_trusted else "data artifacts 缺失或过期"})
+
+    if accepted_done:
+        confirmations.append(
+            {
+                "code": "chapter_already_accepted",
+                "message": "本章已 accepted;重跑前需要确认是重写正文,还是只查看状态/补跑后续步骤。",
+            }
+        )
+    steps.append({"step": "commit", "action": "skip" if commit_done else "run", "reason": f"commit status={commit_status}" if commit_done else "尚未生成 commit"})
+
+    projection_done = bool(commit_status == "accepted" and _projection_done(root, chapter))
+    steps.append({"step": "projection", "action": "skip" if projection_done else "retry", "reason": "资料更新已完成" if projection_done else "需要补跑资料更新"})
+
+    backup_done = _backup_exists(root, chapter)
+    backup_action = "skip" if backup_done else ("retry" if commit_status == "accepted" else "run")
+    steps.append({"step": "backup", "action": backup_action, "reason": "备份已确认" if backup_done else "备份未确认"})
+
+    resume_from = "done"
+    for item in steps:
+        if item["action"] != "skip":
+            resume_from = item["step"]
+            break
+
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "stage": "write",
+        "chapter": int(chapter),
+        "mode": mode or "default",
+        "resume_from": resume_from,
+        "steps": steps,
+        "needs_user_confirmation": confirmations,
+    }
+
+
+def format_resume_plan(plan: dict[str, Any], output_format: str = "json") -> str:
+    if output_format == "json":
+        return json.dumps(plan, ensure_ascii=False, indent=2)
+    lines = [
+        f"resume_from: {plan.get('resume_from')}",
+        f"chapter: {plan.get('chapter')}",
+    ]
+    for item in plan.get("steps") or []:
+        lines.append(f"- {item.get('step')}: {item.get('action')} ({item.get('reason')})")
+    confirmations = plan.get("needs_user_confirmation") or []
+    if confirmations:
+        lines.append("needs_user_confirmation:")
+        lines.extend(f"- {item.get('code')}: {item.get('message')}" for item in confirmations)
+    return "\n".join(lines)
+
+
+def _parse_path_map(raw: str) -> dict[str, str]:
+    if not raw:
+        return {}
+    try:
+        payload = json.loads(raw)
+    except json.JSONDecodeError as exc:
+        raise ValueError(f"不是合法 JSON: {exc}") from exc
+    if not isinstance(payload, dict):
+        raise ValueError("必须是 JSON object")
+    return {str(key): str(value) for key, value in payload.items()}
+
+
+def _parse_string_list(raw: str) -> list[str]:
+    if not raw:
+        return []
+    try:
+        payload = json.loads(raw)
+    except json.JSONDecodeError as exc:
+        raise ValueError(f"不是合法 JSON: {exc}") from exc
+    if not isinstance(payload, list):
+        raise ValueError("必须是 JSON list")
+    return [str(item) for item in payload]
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Record and inspect webnovel write run ledger")
+    parser.add_argument("--project-root", required=True, help="书项目根目录")
+    sub = parser.add_subparsers(dest="action", required=True)
+    record = sub.add_parser("record-write-step", help="记录写章步骤状态")
+    record.add_argument("--chapter", type=int, required=True)
+    record.add_argument("--step", choices=WRITE_STEPS, required=True)
+    record.add_argument("--status", required=True)
+    record.add_argument("--mode", default="default")
+    record.add_argument("--inputs-json", default="{}")
+    record.add_argument("--outputs-json", default="{}")
+    record.add_argument("--problems-json", default="[]")
+    record.add_argument("--auto-handled-json", default="[]")
+    record.add_argument("--duration-ms", type=int, default=0)
+    record.add_argument("--format", choices=["json", "text"], default="json")
+    resume = sub.add_parser("write-resume", help="输出写章断点续跑建议")
+    resume.add_argument("--chapter", type=int, required=True)
+    resume.add_argument("--mode", default="default")
+    resume.add_argument("--format", choices=["json", "text"], default="json")
+    args = parser.parse_args()
+
+    if args.action == "record-write-step":
+        try:
+            entry = record_write_step(
+                args.project_root,
+                chapter=args.chapter,
+                step=args.step,
+                status=args.status,
+                mode=args.mode,
+                inputs=_parse_path_map(args.inputs_json),
+                outputs=_parse_path_map(args.outputs_json),
+                problems=_parse_string_list(args.problems_json),
+                auto_handled=_parse_string_list(args.auto_handled_json),
+                duration_ms=args.duration_ms,
+            )
+        except ValueError as exc:
+            raise SystemExit(str(exc))
+        if args.format == "json":
+            print(json.dumps(entry, ensure_ascii=False, indent=2))
+        else:
+            print(f"{entry['step']}: {entry['status']}")
+        return
+
+    if args.action == "write-resume":
+        plan = build_write_resume_plan(args.project_root, chapter=args.chapter, mode=args.mode)
+        print(format_resume_plan(plan, args.format))
+
+
+if __name__ == "__main__":
+    main()

+ 99 - 0
webnovel-writer/scripts/data_modules/run_logger.py

@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import argparse
+import json
+import re
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+
+SCHEMA_VERSION = "webnovel-run-log/v1"
+LOG_REL = Path(".webnovel") / "logs" / "run_last.log"
+SENSITIVE_KEY_RE = re.compile(r"(api[_-]?key|secret|token|authorization|password|passwd|credential)", re.IGNORECASE)
+ASSIGNMENT_RE = re.compile(
+    r"(?P<key>[A-Za-z0-9_.-]*(?:api[_-]?key|secret|token|authorization|password|passwd|credential)[A-Za-z0-9_.-]*)"
+    r"(?P<sep>\s*[:=]\s*)"
+    r"(?P<value>\"[^\"]*\"|'[^']*'|[^\s,;]+)",
+    re.IGNORECASE,
+)
+
+
+def log_path(project_root: str | Path) -> Path:
+    return Path(project_root) / LOG_REL
+
+
+def redact_text(text: str) -> str:
+    raw = str(text)
+    return ASSIGNMENT_RE.sub(lambda match: f"{match.group('key')}{match.group('sep')}<redacted>", raw)
+
+
+def redact_payload(value: Any) -> Any:
+    if isinstance(value, dict):
+        result: dict[str, Any] = {}
+        for key, item in value.items():
+            if SENSITIVE_KEY_RE.search(str(key)):
+                result[str(key)] = "<redacted>"
+            else:
+                result[str(key)] = redact_payload(item)
+        return result
+    if isinstance(value, list):
+        return [redact_payload(item) for item in value]
+    if isinstance(value, str):
+        return redact_text(value)
+    return value
+
+
+def _now_iso() -> str:
+    return datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+
+def write_run_log(
+    project_root: str | Path,
+    *,
+    event: str,
+    payload: dict[str, Any] | None = None,
+    append: bool = False,
+) -> dict[str, Any]:
+    path = log_path(project_root)
+    path.parent.mkdir(parents=True, exist_ok=True)
+    record = {
+        "schema_version": SCHEMA_VERSION,
+        "created_at": _now_iso(),
+        "event": str(event),
+        "payload": redact_payload(payload or {}),
+    }
+    line = json.dumps(record, ensure_ascii=False, sort_keys=True)
+    mode = "a" if append else "w"
+    with path.open(mode, encoding="utf-8") as handle:
+        handle.write(line)
+        handle.write("\n")
+    return {"schema_version": SCHEMA_VERSION, "path": str(path), "record": record}
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Write a redacted webnovel run log entry")
+    parser.add_argument("--project-root", required=True, help="书项目根目录")
+    parser.add_argument("--event", required=True, help="事件名")
+    parser.add_argument("--payload-json", default="{}", help="要写入日志的 JSON 对象")
+    parser.add_argument("--append", action="store_true", help="追加而不是覆盖 run_last.log")
+    parser.add_argument("--format", choices=["json", "text"], default="json")
+    args = parser.parse_args()
+
+    try:
+        payload = json.loads(args.payload_json)
+    except json.JSONDecodeError as exc:
+        raise SystemExit(f"payload-json 不是合法 JSON: {exc}")
+    if not isinstance(payload, dict):
+        raise SystemExit("payload-json 必须是 JSON object")
+    result = write_run_log(args.project_root, event=args.event, payload=payload, append=args.append)
+    if args.format == "json":
+        print(json.dumps(result, ensure_ascii=False, indent=2))
+    else:
+        print(result["path"])
+
+
+if __name__ == "__main__":
+    main()

+ 53 - 1
webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py

@@ -50,7 +50,8 @@ SUBAGENT_PROMPT_FILES = (
 
 # webnovel.py 注册的子命令(从 add_parser 提取)
 REGISTERED_CLI_SUBCOMMANDS = {
-    "where", "preflight", "project-status", "doctor", "write-gate", "projections", "use",
+    "where", "preflight", "project-status", "doctor", "write-gate", "projections", "user-report",
+    "run-ledger", "run-log", "use",
     "index", "state", "rag", "style", "entity", "context", "memory",
     "migrate", "status", "update-state", "backup", "archive",
     "init", "extract-context", "memory-contract", "project-memory", "review-pipeline",
@@ -402,6 +403,57 @@ def test_agents_expose_subagent_run_summary_signals_without_changing_outputs(age
         assert "不要把 `SubagentRun` JSON 写入任务书" in text
 
 
+@pytest.mark.parametrize("skill_name", AUTHOR_REPORT_SKILLS)
+def test_main_skills_define_author_friendly_progress_and_recovery_contract(skill_name: str):
+    """四个主 Skill 必须有过程提示、少打扰确认、卡住恢复和日志边界。"""
+    text = _read_text(SKILLS_DIR / skill_name / "SKILL.md")
+
+    for required in (
+        "作者友好过程提示与恢复契约",
+        "过程提示",
+        "少打扰确认策略",
+        "有限选项",
+        "卡住时必须说明",
+        "卡点",
+        "已完成内容",
+        "恢复建议",
+        ".webnovel/logs/run_last.log",
+        "run-log",
+        "user-report",
+    ):
+        assert required in text, f"{skill_name}: 缺少过程/恢复契约 {required}"
+    assert "不直接输出原始 JSON" in text or "不输出原始 JSON" in text
+
+
+def test_write_skill_progress_nodes_are_author_friendly_and_limited():
+    """写章过程节点必须压缩到不超过 6 个作者可理解阶段。"""
+    text = _read_text(SKILLS_DIR / "webnovel-write" / "SKILL.md")
+    marker = "写章过程节点(最多 6 个)"
+    assert marker in text
+    section = text[text.find(marker): text.find("## 充分性闸门")]
+    nodes = re.findall(r"^\d+\.\s+(.+)$", section, flags=re.MULTILINE)
+    assert 1 <= len(nodes) <= 6
+    for forbidden in ("write-gate", "chapter-commit", "projection_status", "artifact", "schema"):
+        assert forbidden not in "\n".join(nodes)
+    for friendly in ("检查项目环境", "整理写作依据", "起草正文", "写作检查", "保存本章故事事实", "提交备份"):
+        assert any(friendly in node for node in nodes), f"缺少作者友好节点 {friendly}"
+
+
+def test_write_skill_resume_contract_uses_runtime_ledger_and_confirmation_boundaries():
+    """写章重复执行必须先查可信断点,且在覆盖风险处停下确认。"""
+    text = _read_text(SKILLS_DIR / "webnovel-write" / "SKILL.md")
+    for required in (
+        "run-ledger write-resume",
+        "可信断点",
+        "正文被手动改过",
+        "章纲更新晚于正文",
+        "本章已 accepted",
+        "沿用当前正文 / 重新起草 / 只查看状态",
+        "不得覆盖作者手改",
+    ):
+        assert required in text
+
+
 def test_story_system_runtime_contract_commands_exist():
     text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
     assert "story-system" in text

+ 121 - 0
webnovel-writer/scripts/data_modules/tests/test_run_ledger.py

@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+
+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))
+
+
+_ensure_scripts_on_path()
+
+from data_modules.run_ledger import build_write_resume_plan, record_write_step  # noqa: E402
+
+
+def _write_json(path: Path, payload: dict) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def _make_project(project_root: Path) -> None:
+    (project_root / ".webnovel" / "tmp").mkdir(parents=True, exist_ok=True)
+    (project_root / ".story-system" / "commits").mkdir(parents=True, exist_ok=True)
+    (project_root / "正文").mkdir(parents=True, exist_ok=True)
+    _write_json(project_root / ".webnovel" / "state.json", {"project_info": {"title": "测试书"}, "progress": {}})
+
+
+def _commit_payload(status: str = "accepted") -> dict:
+    return {
+        "meta": {"chapter": 1, "status": status},
+        "projection_status": {
+            "state": "done",
+            "index": "skipped",
+            "summary": "skipped",
+            "memory": "skipped",
+            "vector": "skipped",
+        },
+    }
+
+
+def test_run_ledger_records_write_step_status(tmp_path: Path) -> None:
+    _make_project(tmp_path)
+    chapter_file = tmp_path / "正文" / "第0001章.md"
+    chapter_file.write_text("正文\n", encoding="utf-8")
+
+    entry = record_write_step(
+        tmp_path,
+        chapter=1,
+        step="draft",
+        status="completed",
+        outputs={"chapter_file": chapter_file},
+    )
+
+    assert entry["status"] == "completed"
+    assert entry["outputs"]["chapter_file"]["exists"] is True
+    assert (tmp_path / ".webnovel" / "run_ledger.json").is_file()
+
+
+def test_write_resume_skips_completed_draft_and_review(tmp_path: Path) -> None:
+    _make_project(tmp_path)
+    chapter_file = tmp_path / "正文" / "第0001章.md"
+    chapter_file.write_text("正文\n", encoding="utf-8")
+    review_path = tmp_path / ".webnovel" / "tmp" / "review_results.json"
+    _write_json(review_path, {"blocking_count": 0})
+
+    record_write_step(tmp_path, chapter=1, step="draft", status="completed", outputs={"chapter_file": chapter_file})
+    record_write_step(
+        tmp_path,
+        chapter=1,
+        step="review",
+        status="completed",
+        inputs={"chapter_file": chapter_file},
+        outputs={"review_result": review_path},
+    )
+
+    plan = build_write_resume_plan(tmp_path, chapter=1)
+
+    actions = {item["step"]: item["action"] for item in plan["steps"]}
+    assert actions["draft"] == "skip"
+    assert actions["review"] == "skip"
+    assert actions["data"] == "run"
+
+
+def test_write_resume_rechecks_review_when_chapter_file_changed(tmp_path: Path) -> None:
+    _make_project(tmp_path)
+    chapter_file = tmp_path / "正文" / "第0001章.md"
+    chapter_file.write_text("正文 v1\n", encoding="utf-8")
+    record_write_step(tmp_path, chapter=1, step="draft", status="completed", outputs={"chapter_file": chapter_file})
+    chapter_file.write_text("正文 v2\n", encoding="utf-8")
+
+    plan = build_write_resume_plan(tmp_path, chapter=1)
+
+    actions = {item["step"]: item["action"] for item in plan["steps"]}
+    assert actions["draft"] == "run"
+    assert actions["review"] == "run"
+    assert any(item["code"] == "chapter_file_changed" for item in plan["needs_user_confirmation"])
+
+
+def test_write_resume_retries_backup_after_commit_done(tmp_path: Path) -> None:
+    _make_project(tmp_path)
+    chapter_file = tmp_path / "正文" / "第0001章.md"
+    chapter_file.write_text("正文\n", encoding="utf-8")
+    record_write_step(tmp_path, chapter=1, step="draft", status="completed", outputs={"chapter_file": chapter_file})
+    _write_json(tmp_path / ".story-system" / "commits" / "chapter_001.commit.json", _commit_payload("accepted"))
+
+    plan = build_write_resume_plan(tmp_path, chapter=1)
+
+    actions = {item["step"]: item["action"] for item in plan["steps"]}
+    assert actions["draft"] == "skip"
+    assert actions["review"] == "skip"
+    assert actions["data"] == "skip"
+    assert actions["commit"] == "skip"
+    assert actions["projection"] == "skip"
+    assert actions["backup"] == "retry"
+    assert plan["resume_from"] == "backup"
+    assert any(item["code"] == "chapter_already_accepted" for item in plan["needs_user_confirmation"])

+ 45 - 0
webnovel-writer/scripts/data_modules/tests/test_run_logger.py

@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+
+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))
+
+
+_ensure_scripts_on_path()
+
+from data_modules.run_logger import redact_payload, redact_text, write_run_log  # noqa: E402
+
+
+def test_run_log_redacts_env_values(tmp_path: Path) -> None:
+    result = write_run_log(
+        tmp_path,
+        event="failure",
+        payload={
+            "EMBED_API_KEY": "sk-real-key",
+            "nested": {"authorization": "Bearer token-value"},
+            "message": "RERANK_TOKEN=abc123 normal=value",
+        },
+    )
+
+    log_text = Path(result["path"]).read_text(encoding="utf-8")
+    assert "sk-real-key" not in log_text
+    assert "token-value" not in log_text
+    assert "abc123" not in log_text
+    assert "<redacted>" in log_text
+    record = json.loads(log_text)
+    assert record["payload"]["EMBED_API_KEY"] == "<redacted>"
+    assert record["payload"]["nested"]["authorization"] == "<redacted>"
+
+
+def test_redact_helpers_keep_non_sensitive_content() -> None:
+    assert redact_text("foo=bar") == "foo=bar"
+    payload = redact_payload({"title": "测试书", "api_key": "secret"})
+    assert payload == {"title": "测试书", "api_key": "<redacted>"}

+ 253 - 0
webnovel-writer/scripts/data_modules/tests/test_user_report.py

@@ -0,0 +1,253 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+
+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))
+
+
+_ensure_scripts_on_path()
+
+from data_modules.projection_log import append_projection_run  # noqa: E402
+from data_modules.user_report import build_user_report, render_user_report_text  # noqa: E402
+
+
+def _write_json(path: Path, payload: dict) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def _make_project(project_root: Path) -> None:
+    for rel in (
+        ".webnovel/backups",
+        ".webnovel/archive",
+        ".webnovel/summaries",
+        "设定集",
+        "大纲",
+        "正文",
+        "审查报告",
+    ):
+        (project_root / rel).mkdir(parents=True, exist_ok=True)
+    _write_json(
+        project_root / ".webnovel" / "state.json",
+        {
+            "project_info": {"title": "测试书", "genre": "玄幻"},
+            "progress": {"current_chapter": 0},
+        },
+    )
+    for rel in (
+        "设定集/世界观.md",
+        "设定集/力量体系.md",
+        "设定集/主角卡.md",
+        "设定集/反派设计.md",
+        "大纲/总纲.md",
+        ".env.example",
+    ):
+        path = project_root / rel
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_text("placeholder\n", encoding="utf-8")
+
+
+def _write_review(project_root: Path, *, chapter: int = 1, blocking_count: int = 0, review_skipped: bool = False) -> None:
+    review = {
+        "chapter": chapter,
+        "issues": [],
+        "issues_count": 0,
+        "blocking_count": blocking_count,
+        "has_blocking": blocking_count > 0,
+        "summary": "可继续" if blocking_count == 0 else "有阻断",
+    }
+    if blocking_count:
+        review["issues"] = [
+            {
+                "severity": "critical",
+                "category": "timeline",
+                "location": "第2段",
+                "description": "时间线断裂",
+                "fix_hint": "补过渡",
+                "blocking": True,
+            }
+        ]
+        review["issues_count"] = 1
+    if review_skipped:
+        review["review_skipped"] = True
+        review["review_mode"] = "minimal"
+    _write_json(project_root / ".webnovel" / "tmp" / "review_results.json", review)
+    _write_json(
+        project_root / ".webnovel" / "tmp" / "review_metrics.json",
+        {
+            "start_chapter": chapter,
+            "end_chapter": chapter,
+            "issues_count": review["issues_count"],
+            "blocking_count": blocking_count,
+            "report_file": f"审查报告/第{chapter}章审查报告.md",
+        },
+    )
+    report_path = project_root / "审查报告" / f"第{chapter}章审查报告.md"
+    report_path.parent.mkdir(parents=True, exist_ok=True)
+    report_path.write_text("# 审查报告\n", encoding="utf-8")
+
+
+def _write_data_artifacts(project_root: Path) -> None:
+    _write_json(
+        project_root / ".webnovel" / "tmp" / "fulfillment_result.json",
+        {"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
+    )
+    _write_json(project_root / ".webnovel" / "tmp" / "disambiguation_result.json", {"pending": []})
+    _write_json(
+        project_root / ".webnovel" / "tmp" / "extraction_result.json",
+        {"accepted_events": [], "state_deltas": [], "entity_deltas": [], "summary_text": "摘要"},
+    )
+
+
+def _commit_payload(*, chapter: int = 1, status: str = "accepted", projection_status: dict | None = None) -> dict:
+    return {
+        "meta": {"chapter": chapter, "status": status},
+        "review_result": {"blocking_count": 0},
+        "fulfillment_result": {"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
+        "disambiguation_result": {"pending": []},
+        "extraction_result": {"accepted_events": [], "state_deltas": [], "entity_deltas": [], "summary_text": "摘要"},
+        "projection_status": projection_status
+        or {"state": "done", "index": "skipped", "summary": "skipped", "memory": "skipped", "vector": "skipped"},
+    }
+
+
+def _write_commit(project_root: Path, payload: dict) -> Path:
+    chapter = int(payload["meta"]["chapter"])
+    path = project_root / ".story-system" / "commits" / f"chapter_{chapter:03d}.commit.json"
+    _write_json(path, payload)
+    return path
+
+
+def _write_success_case(project_root: Path, *, chapter: int = 1) -> None:
+    _make_project(project_root)
+    (project_root / "正文" / f"第{chapter:04d}章.md").write_text("正文\n", encoding="utf-8")
+    _write_review(project_root, chapter=chapter)
+    _write_data_artifacts(project_root)
+    _write_commit(project_root, _commit_payload(chapter=chapter))
+    (project_root / ".webnovel" / "backups" / f"ch{chapter:04d}_ok").mkdir(parents=True, exist_ok=True)
+
+
+def test_render_write_report_success(tmp_path: Path) -> None:
+    _write_success_case(tmp_path, chapter=1)
+
+    report = build_user_report(tmp_path, stage="write", chapter=1)
+    text = render_user_report_text(report)
+
+    assert report["schema_version"] == "webnovel-user-report/v1"
+    assert report["overall_status"] == "completed"
+    assert report["stage"] == "write"
+    assert any(item["label"] == "正文" and item["status"] == "completed" for item in report["files"])
+    assert not report["issues"]["must_handle"]
+    assert "/webnovel-write 2" in text
+    assert "总状态:已完成。" in text
+    assert "一、产生的文件与完成情况" in text
+    assert "二、过程中遇到的问题与异常耗时" in text
+    assert "三、下一步建议" in text
+
+
+def test_render_write_report_commit_rejected(tmp_path: Path) -> None:
+    _write_success_case(tmp_path, chapter=1)
+    payload = _commit_payload(chapter=1, status="rejected")
+    payload["review_result"] = {"blocking_count": 1}
+    _write_commit(tmp_path, payload)
+
+    report = build_user_report(tmp_path, stage="write", chapter=1)
+
+    assert report["overall_status"] == "needs_user"
+    titles = [item["title"] for item in report["issues"]["must_handle"]]
+    assert "本章事实没有通过提交" in titles
+
+
+def test_render_write_report_projection_failed(tmp_path: Path) -> None:
+    _write_success_case(tmp_path, chapter=1)
+    _write_commit(
+        tmp_path,
+        _commit_payload(
+            chapter=1,
+            projection_status={"state": "done", "index": "failed:locked", "summary": "skipped", "memory": "skipped", "vector": "skipped"},
+        ),
+    )
+
+    report = build_user_report(tmp_path, stage="write", chapter=1)
+
+    assert report["overall_status"] == "needs_user"
+    assert any(item["title"] == "故事资料更新失败" for item in report["issues"]["must_handle"])
+
+
+def test_render_write_report_projection_retry_success_is_auto_handled(tmp_path: Path) -> None:
+    _write_success_case(tmp_path, chapter=1)
+    payload = _commit_payload(
+        chapter=1,
+        projection_status={"state": "done", "index": "failed:locked", "summary": "skipped", "memory": "skipped", "vector": "skipped"},
+    )
+    commit_path = _write_commit(tmp_path, payload)
+    append_projection_run(
+        tmp_path,
+        payload,
+        {"index": {"status": "failed:locked"}},
+        commit_path=commit_path,
+    )
+    append_projection_run(
+        tmp_path,
+        payload,
+        {
+            "state": {"status": "done"},
+            "index": {"status": "skipped"},
+            "summary": {"status": "skipped"},
+            "memory": {"status": "skipped"},
+            "vector": {"status": "skipped"},
+        },
+        commit_path=commit_path,
+    )
+
+    report = build_user_report(tmp_path, stage="write", chapter=1)
+
+    assert report["overall_status"] == "completed"
+    assert any(item["code"] == "projection retry" for item in report["issues"]["auto_handled"])
+    assert not report["issues"]["must_handle"]
+
+
+def test_render_review_report_blocking(tmp_path: Path) -> None:
+    _make_project(tmp_path)
+    _write_review(tmp_path, chapter=4, blocking_count=1)
+
+    report = build_user_report(tmp_path, stage="review", chapter=4)
+
+    assert report["overall_status"] == "needs_user"
+    assert report["review_author_view"]["status"] == "must_fix"
+    assert any(item["code"] == "blocking_review" for item in report["issues"]["must_handle"])
+
+
+def test_missing_artifact_does_not_crash_and_is_not_completed(tmp_path: Path) -> None:
+    _make_project(tmp_path)
+    (tmp_path / "正文" / "第0001章.md").write_text("正文\n", encoding="utf-8")
+
+    report = build_user_report(tmp_path, stage="write", chapter=1)
+    text = render_user_report_text(report)
+
+    assert report["overall_status"] in {"needs_user", "failed"}
+    assert report["issues"]["must_handle"]
+    assert "总状态:已完成。" not in text
+
+
+def test_user_report_includes_log_path_only_on_failure(tmp_path: Path) -> None:
+    _make_project(tmp_path)
+
+    failed = build_user_report(tmp_path, stage="write", chapter=1)
+    failed_text = render_user_report_text(failed)
+    assert failed["overall_status"] == "failed"
+    assert ".webnovel/logs/run_last.log" in failed_text
+
+    _write_success_case(tmp_path, chapter=1)
+    completed = build_user_report(tmp_path, stage="write", chapter=1)
+    completed_text = render_user_report_text(completed)
+    assert completed["overall_status"] == "completed"
+    assert ".webnovel/logs/run_last.log" not in completed_text

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

@@ -383,6 +383,132 @@ def test_project_status_cli_outputs_json_without_reusing_status(monkeypatch, tmp
     assert report["phase"] == "init_ready"
 
 
+def test_user_report_cli_outputs_json(monkeypatch, tmp_path, capsys):
+    module = _load_webnovel_module()
+    project_root = tmp_path / "book"
+    _make_cli_init_ready_project(project_root)
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "webnovel",
+            "--project-root",
+            str(project_root),
+            "user-report",
+            "--stage",
+            "init",
+            "--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) == 0
+    assert report["schema_version"] == "webnovel-user-report/v1"
+    assert report["stage"] == "init"
+    assert report["overall_status"] == "completed"
+
+
+def test_run_ledger_cli_records_and_reports_resume(monkeypatch, tmp_path, capsys):
+    module = _load_webnovel_module()
+    project_root = tmp_path / "book"
+    _make_cli_init_ready_project(project_root)
+    chapter_file = project_root / "正文" / "第0001章.md"
+    chapter_file.write_text("正文\n", encoding="utf-8")
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "webnovel",
+            "--project-root",
+            str(project_root),
+            "run-ledger",
+            "record-write-step",
+            "--chapter",
+            "1",
+            "--step",
+            "draft",
+            "--status",
+            "completed",
+            "--outputs-json",
+            json.dumps({"chapter_file": str(chapter_file)}, ensure_ascii=False),
+            "--format",
+            "json",
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    entry = json.loads(capsys.readouterr().out)
+    assert int(exc.value.code or 0) == 0
+    assert entry["step"] == "draft"
+    assert entry["outputs"]["chapter_file"]["exists"] is True
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "webnovel",
+            "--project-root",
+            str(project_root),
+            "run-ledger",
+            "write-resume",
+            "--chapter",
+            "1",
+            "--format",
+            "json",
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    resume = json.loads(capsys.readouterr().out)
+    assert int(exc.value.code or 0) == 0
+    assert resume["schema_version"] == "webnovel-run-ledger/v1"
+    assert resume["steps"][0]["step"] == "draft"
+    assert resume["steps"][0]["action"] == "skip"
+
+
+def test_run_log_cli_redacts_sensitive_payload(monkeypatch, tmp_path, capsys):
+    module = _load_webnovel_module()
+    project_root = tmp_path / "book"
+    _make_cli_init_ready_project(project_root)
+
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "webnovel",
+            "--project-root",
+            str(project_root),
+            "run-log",
+            "--event",
+            "failure",
+            "--payload-json",
+            json.dumps({"api_key": "secret-value", "message": "ok"}, ensure_ascii=False),
+            "--format",
+            "json",
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        module.main()
+
+    result = json.loads(capsys.readouterr().out)
+    assert int(exc.value.code or 0) == 0
+    log_text = Path(result["path"]).read_text(encoding="utf-8")
+    assert "secret-value" not in log_text
+    assert "<redacted>" in log_text
+
+
 def test_doctor_cli_reports_missing_init_file(monkeypatch, tmp_path, capsys):
     module = _load_webnovel_module()
     project_root = tmp_path / "book"

+ 803 - 0
webnovel-writer/scripts/data_modules/user_report.py

@@ -0,0 +1,803 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import Any
+
+try:
+    from chapter_paths import find_chapter_file
+except ImportError:  # pragma: no cover
+    from scripts.chapter_paths import find_chapter_file
+
+from .artifact_validator import (
+    OK_PROJECTION_STATUSES,
+    REQUIRED_PROJECTION_WRITERS,
+    validate_disambiguation_result,
+    validate_extraction_result,
+    validate_fulfillment_result,
+    validate_review_result,
+)
+from .error_catalog import AuthorError, classify_issue
+from .project_phase import (
+    COMMIT_ARTIFACT_FILES,
+    INIT_REQUIRED_DIRS,
+    INIT_REQUIRED_FILES,
+    contract_files_for_chapter,
+)
+from .project_status import build_project_status
+from .projection_log import (
+    latest_projection_run,
+    projection_run_failed,
+    projection_run_pending,
+    projection_status_from_run,
+    read_projection_runs,
+)
+from .review_author_view import build_review_author_view
+
+
+SCHEMA_VERSION = "webnovel-user-report/v1"
+VALID_STAGES = ("init", "plan", "write", "review")
+VALID_FORMATS = ("text", "json")
+
+STATUS_COMPLETED = "completed"
+STATUS_PARTIAL = "partial"
+STATUS_NEEDS_USER = "needs_user"
+STATUS_FAILED = "failed"
+
+STATUS_TEXT = {
+    STATUS_COMPLETED: "已完成",
+    STATUS_PARTIAL: "部分完成",
+    STATUS_NEEDS_USER: "需要你处理",
+    STATUS_FAILED: "未完成",
+}
+
+
+def _read_json(path: Path) -> tuple[dict[str, Any], str]:
+    try:
+        payload = json.loads(path.read_text(encoding="utf-8"))
+    except FileNotFoundError:
+        return {}, "missing"
+    except json.JSONDecodeError as exc:
+        return {}, f"invalid_json:{exc}"
+    except OSError as exc:
+        return {}, f"read_error:{exc}"
+    if not isinstance(payload, dict):
+        return {}, "not_object"
+    return payload, ""
+
+
+def _rel(project_root: Path, path: Path | str) -> str:
+    raw = Path(path)
+    try:
+        return raw.resolve().relative_to(project_root.resolve()).as_posix()
+    except Exception:
+        return str(path)
+
+
+def _new_report(
+    project_root: Path,
+    *,
+    stage: str,
+    chapter: int | None = None,
+    volume: int | None = None,
+) -> dict[str, Any]:
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "stage": stage,
+        "overall_status": STATUS_COMPLETED,
+        "project_root": str(project_root),
+        "chapter": int(chapter or 0),
+        "volume": int(volume or 0),
+        "files": [],
+        "issues": {
+            "auto_handled": [],
+            "needs_confirmation": [],
+            "must_handle": [],
+        },
+        "timing": {
+            "total_ms": 0,
+            "steps": [],
+        },
+        "next_actions": [],
+    }
+
+
+def _add_file(
+    report: dict[str, Any],
+    *,
+    label: str,
+    path: Path | str,
+    status: str,
+    note: str = "",
+) -> None:
+    report["files"].append(
+        {
+            "label": label,
+            "path": str(path),
+            "status": status,
+            "note": note,
+        }
+    )
+
+
+def _issue_from_author_error(
+    error: AuthorError,
+    *,
+    source: str = "",
+    path: str = "",
+    message: str = "",
+) -> dict[str, Any]:
+    return {
+        "code": error.code,
+        "title": error.title,
+        "reason": error.reason,
+        "impact": error.impact,
+        "next_action": error.next_action,
+        "command": error.command,
+        "source": source,
+        "path": path,
+        "message": message,
+        "auto_handle": error.auto_handle,
+        "matched": error.matched,
+    }
+
+
+def _add_classified_issue(
+    report: dict[str, Any],
+    issue: Any,
+    *,
+    source: str = "",
+    path: str = "",
+    message: str = "",
+    severity: str | None = None,
+) -> None:
+    error = classify_issue(issue)
+    bucket = severity or error.severity
+    if bucket not in report["issues"]:
+        bucket = "must_handle"
+    entry = _issue_from_author_error(error, source=source, path=path, message=message)
+    if entry not in report["issues"][bucket]:
+        report["issues"][bucket].append(entry)
+
+
+def _add_manual_issue(
+    report: dict[str, Any],
+    bucket: str,
+    *,
+    code: str,
+    title: str,
+    reason: str,
+    impact: str,
+    next_action: str,
+    command: str = "",
+    source: str = "",
+    path: str = "",
+    message: str = "",
+    auto_handle: bool = False,
+) -> None:
+    if bucket not in report["issues"]:
+        bucket = "must_handle"
+    entry = {
+        "code": code,
+        "title": title,
+        "reason": reason,
+        "impact": impact,
+        "next_action": next_action,
+        "command": command,
+        "source": source,
+        "path": path,
+        "message": message,
+        "auto_handle": auto_handle,
+        "matched": True,
+    }
+    if entry not in report["issues"][bucket]:
+        report["issues"][bucket].append(entry)
+
+
+def _artifact_paths(project_root: Path) -> dict[str, Path]:
+    return {
+        "review_result": project_root / COMMIT_ARTIFACT_FILES[0],
+        "fulfillment_result": project_root / COMMIT_ARTIFACT_FILES[1],
+        "disambiguation_result": project_root / COMMIT_ARTIFACT_FILES[2],
+        "extraction_result": project_root / COMMIT_ARTIFACT_FILES[3],
+    }
+
+
+def _validate_artifact_for_report(
+    report: dict[str, Any],
+    artifact: str,
+    path: Path,
+) -> dict[str, Any]:
+    validators = {
+        "review_result": validate_review_result,
+        "fulfillment_result": validate_fulfillment_result,
+        "disambiguation_result": validate_disambiguation_result,
+        "extraction_result": validate_extraction_result,
+    }
+    validator = validators[artifact]
+    result = validator(path)
+    file_status = "completed" if result.get("ok") else "failed"
+    note = "已生成" if result.get("ok") else "缺失或格式不完整"
+    _add_file(report, label=artifact, path=_rel(Path(report["project_root"]), path), status=file_status, note=note)
+    for item in result.get("errors") or []:
+        issue = {
+            "code": item.get("type") or item.get("code") or "",
+            "message": item.get("message") or "",
+            "impact": item.get("impact") or "",
+            "repair": item.get("repair") or "",
+        }
+        if str(issue["code"]) == "blocking_review":
+            _add_classified_issue(
+                report,
+                {"code": "blocking_review", "message": issue["message"]},
+                source=artifact,
+                path=_rel(Path(report["project_root"]), path),
+                message=str(issue["message"] or ""),
+                severity="must_handle",
+            )
+        else:
+            _add_classified_issue(
+                report,
+                issue,
+                source=artifact,
+                path=_rel(Path(report["project_root"]), path),
+                message=str(issue["message"] or ""),
+            )
+    return result
+
+
+def _review_metrics_path(project_root: Path) -> Path:
+    return project_root / ".webnovel" / "tmp" / "review_metrics.json"
+
+
+def _review_report_path_from_metrics(project_root: Path, metrics: dict[str, Any]) -> Path | None:
+    report_file = str(metrics.get("report_file") or "").strip()
+    if not report_file:
+        return None
+    path = Path(report_file)
+    if not path.is_absolute():
+        path = project_root / path
+    return path
+
+
+def _find_review_report(project_root: Path, chapter: int) -> Path | None:
+    metrics, _ = _read_json(_review_metrics_path(project_root))
+    path = _review_report_path_from_metrics(project_root, metrics)
+    if path and path.is_file():
+        return path
+    reports_dir = project_root / "审查报告"
+    if not reports_dir.is_dir():
+        return None
+    patterns = (f"*第{chapter}章*.md", f"*第{chapter:02d}章*.md", f"*第{chapter:03d}章*.md")
+    for pattern in patterns:
+        matches = sorted(reports_dir.rglob(pattern))
+        for item in matches:
+            if item.is_file():
+                return item
+    return None
+
+
+def _commit_path(project_root: Path, chapter: int) -> Path:
+    return project_root / ".story-system" / "commits" / f"chapter_{chapter:03d}.commit.json"
+
+
+def _projection_status_from_commit(payload: dict[str, Any]) -> dict[str, str]:
+    raw = payload.get("projection_status") if isinstance(payload, dict) else {}
+    if not isinstance(raw, dict):
+        return {}
+    return {str(key): str(value) for key, value in raw.items()}
+
+
+def _is_projection_ok(statuses: dict[str, str]) -> bool:
+    if not statuses:
+        return False
+    for writer in REQUIRED_PROJECTION_WRITERS:
+        if str(statuses.get(writer) or "") not in OK_PROJECTION_STATUSES:
+            return False
+    return True
+
+
+def _projection_failed(statuses: dict[str, str]) -> bool:
+    return any(str(value).startswith("failed") for value in statuses.values())
+
+
+def _projection_pending_or_missing(statuses: dict[str, str]) -> bool:
+    if not statuses:
+        return True
+    return any(
+        not str(statuses.get(writer) or "").strip()
+        or str(statuses.get(writer) or "") == "pending"
+        or str(statuses.get(writer) or "") not in OK_PROJECTION_STATUSES
+        for writer in REQUIRED_PROJECTION_WRITERS
+    )
+
+
+def _add_projection_issues(
+    report: dict[str, Any],
+    project_root: Path,
+    chapter: int,
+    commit_payload: dict[str, Any],
+) -> None:
+    runs = read_projection_runs(project_root, chapter=chapter)
+    latest_run = latest_projection_run(project_root, chapter=chapter)
+    latest_statuses = projection_status_from_run(latest_run) if latest_run else {}
+    status_source = "projection_log" if latest_statuses else "commit"
+    statuses = latest_statuses or _projection_status_from_commit(commit_payload)
+
+    had_failed_or_pending = any(projection_run_failed(run) or projection_run_pending(run) for run in runs[:-1])
+    latest_ok = bool(latest_run and _is_projection_ok(statuses))
+    if had_failed_or_pending and latest_ok:
+        _add_manual_issue(
+            report,
+            "auto_handled",
+            code="projection retry",
+            title="故事资料更新已补跑成功",
+            reason="系统曾遇到资料同步失败或等待状态,随后已有成功的 projection 记录。",
+            impact="本章事实已经同步到可用的故事资料,不影响继续写下一章。",
+            next_action="本次无需处理;如果想核对,可查看 `.webnovel/projection_log.jsonl`。",
+            source=status_source,
+            path=".webnovel/projection_log.jsonl",
+            auto_handle=True,
+        )
+        return
+
+    if _projection_failed(statuses):
+        _add_classified_issue(
+            report,
+            {"code": "projection_failure", "message": str(statuses)},
+            source=status_source,
+            path=".webnovel/projection_log.jsonl" if status_source == "projection_log" else _rel(project_root, _commit_path(project_root, chapter)),
+            message=str(statuses),
+        )
+    elif _projection_pending_or_missing(statuses):
+        _add_classified_issue(
+            report,
+            {"code": "projection_status_missing", "message": str(statuses)},
+            source=status_source,
+            path=".webnovel/projection_log.jsonl" if status_source == "projection_log" else _rel(project_root, _commit_path(project_root, chapter)),
+            message=str(statuses),
+        )
+
+
+def _backup_evidence(project_root: Path, chapter: int) -> tuple[bool, str]:
+    backup_dir = project_root / ".webnovel" / "backups"
+    if backup_dir.is_dir():
+        patterns = (f"ch{chapter:04d}*", f"*{chapter:04d}*", f"*第{chapter}章*")
+        for pattern in patterns:
+            if any(backup_dir.glob(pattern)):
+                return True, _rel(project_root, backup_dir)
+    return False, _rel(project_root, backup_dir)
+
+
+def _status_from_issues(report: dict[str, Any], *, core_file_count: int = 0) -> str:
+    issues = report.get("issues") or {}
+    if issues.get("must_handle"):
+        return STATUS_NEEDS_USER if core_file_count > 0 else STATUS_FAILED
+    if issues.get("needs_confirmation"):
+        return STATUS_PARTIAL
+    return STATUS_COMPLETED
+
+
+def _append_project_status_next_action(report: dict[str, Any], project_root: Path, chapter: int | None) -> None:
+    try:
+        status = build_project_status(project_root, chapter=chapter)
+    except Exception:
+        status = {}
+    next_action = str(status.get("next_action") or "").strip()
+    if next_action:
+        report["next_actions"].append(
+            {
+                "label": "继续当前项目",
+                "description": next_action,
+                "command": next_action if next_action.startswith("/") else "",
+            }
+        )
+
+
+def build_write_report(project_root: Path, *, chapter: int, volume: int | None = None) -> dict[str, Any]:
+    report = _new_report(project_root, stage="write", chapter=chapter, volume=volume)
+    core_files = 0
+
+    chapter_file = find_chapter_file(project_root, chapter)
+    if chapter_file:
+        core_files += 1
+        _add_file(report, label="正文", path=_rel(project_root, chapter_file), status="completed", note="已生成")
+    else:
+        _add_file(report, label="正文", path=_rel(project_root, project_root / "正文"), status="missing", note="未找到本章正文")
+        _add_manual_issue(
+            report,
+            "must_handle",
+            code="chapter_file_missing",
+            title="正文文件缺失",
+            reason="没有找到本章正文文件。",
+            impact="当前章节不能提交为故事事实。",
+            next_action="重新运行同一条写章命令,让系统从正文步骤继续。",
+            command=f"/webnovel-write {chapter}",
+            path="正文",
+        )
+
+    review_report = _find_review_report(project_root, chapter)
+    if review_report:
+        _add_file(report, label="审查报告", path=_rel(project_root, review_report), status="completed", note="已生成")
+    else:
+        _add_file(report, label="审查报告", path="审查报告", status="missing", note="未找到审查报告文件")
+
+    artifact_results: dict[str, dict[str, Any]] = {}
+    for artifact, path in _artifact_paths(project_root).items():
+        artifact_results[artifact] = _validate_artifact_for_report(report, artifact, path)
+
+    review_payload = artifact_results.get("review_result", {}).get("payload") or {}
+    if isinstance(review_payload, dict) and review_payload.get("review_skipped"):
+        _add_manual_issue(
+            report,
+            "needs_confirmation",
+            code="review_skipped",
+            title="写作检查已按 minimal 模式跳过",
+            reason="本轮写章使用了 no-review artifact,未经过完整 reviewer 审查。",
+            impact="正文可以继续保存,但质量风险需要你自行决定是否接受。",
+            next_action="如果想补审,运行 `/webnovel-review` 查看本章问题。",
+            command="/webnovel-review",
+            source="review_result",
+            path=COMMIT_ARTIFACT_FILES[0],
+        )
+
+    commit_path = _commit_path(project_root, chapter)
+    commit_payload, commit_error = _read_json(commit_path)
+    if commit_error:
+        _add_file(report, label="本章事实提交", path=_rel(project_root, commit_path), status="missing", note="未生成 commit")
+        _add_classified_issue(
+            report,
+            {"code": "missing_artifact", "message": "chapter commit missing"},
+            source="chapter_commit",
+            path=_rel(project_root, commit_path),
+            message="chapter commit missing",
+        )
+    else:
+        core_files += 1
+        status = str((commit_payload.get("meta") or {}).get("status") or "")
+        file_status = "completed" if status == "accepted" else "failed"
+        note = f"status={status or 'missing'}"
+        _add_file(report, label="本章事实提交", path=_rel(project_root, commit_path), status=file_status, note=note)
+        if status == "rejected":
+            _add_classified_issue(
+                report,
+                {"code": "chapter-commit rejected", "message": "chapter commit rejected"},
+                source="chapter_commit",
+                path=_rel(project_root, commit_path),
+                message="chapter commit rejected",
+            )
+        elif status != "accepted":
+            _add_manual_issue(
+                report,
+                "must_handle",
+                code="commit_status_unknown",
+                title="本章事实提交状态不明确",
+                reason=f"commit 状态是 `{status or 'missing'}`,不是 accepted。",
+                impact="系统不能确认本章是否已正式进入故事主链。",
+                next_action="运行 `/webnovel-doctor` 查看详情,必要时重新提交本章事实。",
+                command="/webnovel-doctor",
+                source="chapter_commit",
+                path=_rel(project_root, commit_path),
+            )
+        else:
+            _add_projection_issues(report, project_root, chapter, commit_payload)
+
+    backup_ok, backup_path = _backup_evidence(project_root, chapter)
+    _add_file(
+        report,
+        label="备份",
+        path=backup_path,
+        status="completed" if backup_ok else "unknown",
+        note="已找到备份记录" if backup_ok else "未找到可确认的备份记录",
+    )
+    if commit_payload and str((commit_payload.get("meta") or {}).get("status") or "") == "accepted" and not backup_ok:
+        _add_manual_issue(
+            report,
+            "needs_confirmation",
+            code="backup_unconfirmed",
+            title="备份状态未确认",
+            reason="没有在 `.webnovel/backups` 找到本章备份证据。",
+            impact="本章事实已生成,但回滚保障需要再确认。",
+            next_action="运行备份命令或重新执行写章收尾步骤。",
+            command=f"/webnovel-write {chapter}",
+            source="backup",
+            path=backup_path,
+        )
+
+    report["overall_status"] = _status_from_issues(report, core_file_count=core_files)
+    if report["overall_status"] == STATUS_COMPLETED:
+        report["next_actions"].append(
+            {
+                "label": "写下一章",
+                "description": f"可以继续写第 {chapter + 1} 章。",
+                "command": f"/webnovel-write {chapter + 1}",
+            }
+        )
+    elif report["issues"]["must_handle"]:
+        report["next_actions"].append(
+            {
+                "label": "先处理阻断项",
+                "description": "先处理“必须处理”里的问题,再重新运行同一条写章命令。",
+                "command": f"/webnovel-write {chapter}",
+            }
+        )
+    else:
+        _append_project_status_next_action(report, project_root, chapter)
+    return report
+
+
+def _load_review_result(project_root: Path) -> tuple[dict[str, Any], Path, str]:
+    path = project_root / ".webnovel" / "tmp" / "review_results.json"
+    payload, error = _read_json(path)
+    return payload, path, error
+
+
+def build_review_report(project_root: Path, *, chapter: int, volume: int | None = None) -> dict[str, Any]:
+    report = _new_report(project_root, stage="review", chapter=chapter, volume=volume)
+    core_files = 0
+    review_result, review_path, review_error = _load_review_result(project_root)
+    if review_error:
+        _add_file(report, label="审查结果", path=_rel(project_root, review_path), status="missing", note="未找到 review_results.json")
+        _add_classified_issue(
+            report,
+            {"code": "missing_artifact", "message": "review_results missing"},
+            source="review",
+            path=_rel(project_root, review_path),
+            message="review_results missing",
+        )
+    else:
+        core_files += 1
+        blocking_count = int(review_result.get("blocking_count") or 0)
+        _add_file(report, label="审查结果", path=_rel(project_root, review_path), status="completed", note=f"blocking={blocking_count}")
+        view = build_review_author_view({"review_result": review_result})
+        report["review_author_view"] = view.to_dict()
+        if blocking_count > 0:
+            _add_manual_issue(
+                report,
+                "must_handle",
+                code="blocking_review",
+                title="审查发现必须先处理的问题",
+                reason=f"本章有 {blocking_count} 个 blocking 问题。",
+                impact="不处理会影响继续写作、提交或事实一致性。",
+                next_action="先按审查报告处理阻断问题;如果要保留当前版本,需要用户明确裁决。",
+                command="/webnovel-review",
+                source="review",
+                path=_rel(project_root, review_path),
+            )
+
+    metrics_path = _review_metrics_path(project_root)
+    metrics, metrics_error = _read_json(metrics_path)
+    if metrics_error:
+        _add_file(report, label="审查指标", path=_rel(project_root, metrics_path), status="missing", note="未找到 review_metrics.json")
+        _add_manual_issue(
+            report,
+            "needs_confirmation",
+            code="review_metrics_missing",
+            title="审查指标未落盘",
+            reason="没有找到 `.webnovel/tmp/review_metrics.json`。",
+            impact="审查正文可读,但 dashboard 或趋势统计可能缺少本章记录。",
+            next_action="重新运行审查流程并保存 metrics。",
+            command="/webnovel-review",
+            source="review",
+            path=_rel(project_root, metrics_path),
+        )
+    else:
+        _add_file(report, label="审查指标", path=_rel(project_root, metrics_path), status="completed", note="已生成")
+
+    report_path = _review_report_path_from_metrics(project_root, metrics) if metrics else None
+    if report_path and report_path.is_file():
+        _add_file(report, label="审查报告文件", path=_rel(project_root, report_path), status="completed", note="已生成")
+    else:
+        found = _find_review_report(project_root, chapter)
+        if found:
+            _add_file(report, label="审查报告文件", path=_rel(project_root, found), status="completed", note="已生成")
+        else:
+            _add_file(report, label="审查报告文件", path="审查报告", status="missing", note="未找到审查报告文件")
+            _add_manual_issue(
+                report,
+                "needs_confirmation",
+                code="review_report_missing",
+                title="审查报告文件未找到",
+                reason="没有找到面向阅读的审查报告 Markdown 文件。",
+                impact="审查 JSON 仍可用,但你不方便直接阅读修改建议。",
+                next_action="重新运行审查流程并指定 report file。",
+                command="/webnovel-review",
+                source="review",
+                path="审查报告",
+            )
+
+    report["overall_status"] = _status_from_issues(report, core_file_count=core_files)
+    if report["issues"]["must_handle"]:
+        report["next_actions"].append(
+            {
+                "label": "处理审查问题",
+                "description": "先处理审查报告中的阻断问题,再继续写作或提交。",
+                "command": "/webnovel-review",
+            }
+        )
+    else:
+        report["next_actions"].append(
+            {
+                "label": "继续写作",
+                "description": f"如果本章已满意,可以继续写第 {chapter + 1} 章。",
+                "command": f"/webnovel-write {chapter + 1}",
+            }
+        )
+    return report
+
+
+def build_init_report(project_root: Path, *, chapter: int | None = None, volume: int | None = None) -> dict[str, Any]:
+    report = _new_report(project_root, stage="init", chapter=chapter, volume=volume)
+    core_files = 0
+    for rel in INIT_REQUIRED_DIRS:
+        path = project_root / rel
+        if path.is_dir():
+            core_files += 1
+            _add_file(report, label=rel, path=rel, status="completed", note="已创建")
+        else:
+            _add_file(report, label=rel, path=rel, status="missing", note="缺少目录")
+            _add_classified_issue(report, {"code": "mainline_ready=false", "message": rel}, source="init", path=rel)
+    for rel in INIT_REQUIRED_FILES:
+        path = project_root / rel
+        if path.is_file():
+            core_files += 1
+            _add_file(report, label=rel, path=rel, status="completed", note="已生成")
+        else:
+            _add_file(report, label=rel, path=rel, status="missing", note="缺少文件")
+            _add_classified_issue(report, {"code": "mainline_ready=false", "message": rel}, source="init", path=rel)
+    report["overall_status"] = _status_from_issues(report, core_file_count=core_files)
+    _append_project_status_next_action(report, project_root, chapter)
+    return report
+
+
+def build_plan_report(project_root: Path, *, chapter: int | None = None, volume: int | None = None) -> dict[str, Any]:
+    target_chapter = int(chapter or 1)
+    report = _new_report(project_root, stage="plan", chapter=target_chapter, volume=volume)
+    core_files = 0
+    outline_path = project_root / "大纲" / "总纲.md"
+    if outline_path.is_file():
+        core_files += 1
+        _add_file(report, label="总纲", path=_rel(project_root, outline_path), status="completed", note="已生成")
+    else:
+        _add_file(report, label="总纲", path="大纲/总纲.md", status="missing", note="缺少总纲")
+        _add_classified_issue(report, {"code": "mainline_ready=false", "message": "missing outline"}, source="plan", path="大纲/总纲.md")
+
+    for label, path in contract_files_for_chapter(project_root, target_chapter).items():
+        if path.is_file():
+            core_files += 1
+            _add_file(report, label=f"Story System {label}", path=_rel(project_root, path), status="completed", note="合同已生成")
+        else:
+            _add_file(report, label=f"Story System {label}", path=_rel(project_root, path), status="missing", note="合同缺失")
+            _add_classified_issue(report, {"code": "mainline_ready=false", "message": f"missing {label} contract"}, source="plan", path=_rel(project_root, path))
+
+    report["overall_status"] = _status_from_issues(report, core_file_count=core_files)
+    _append_project_status_next_action(report, project_root, target_chapter)
+    return report
+
+
+def build_user_report(
+    project_root: str | Path,
+    *,
+    stage: str,
+    chapter: int | None = None,
+    volume: int | None = None,
+) -> dict[str, Any]:
+    root = Path(project_root)
+    stage = str(stage or "").strip()
+    if stage not in VALID_STAGES:
+        raise ValueError(f"unknown user report stage: {stage}")
+
+    if stage == "write":
+        if not chapter:
+            status = build_project_status(root)
+            chapter = int(status.get("target_chapter") or 0)
+        return build_write_report(root, chapter=int(chapter or 0), volume=volume)
+    if stage == "review":
+        if not chapter:
+            status = build_project_status(root)
+            chapter = int(status.get("target_chapter") or 0)
+        return build_review_report(root, chapter=int(chapter or 0), volume=volume)
+    if stage == "init":
+        return build_init_report(root, chapter=chapter, volume=volume)
+    return build_plan_report(root, chapter=chapter, volume=volume)
+
+
+def _format_issue_item(item: dict[str, Any]) -> str:
+    title = str(item.get("title") or item.get("code") or "问题")
+    impact = str(item.get("impact") or "").strip()
+    action = str(item.get("next_action") or "").strip()
+    command = str(item.get("command") or "").strip()
+    parts = [title]
+    if impact:
+        parts.append(f"影响:{impact}")
+    if action:
+        parts.append(f"下一步:{action}")
+    if command:
+        parts.append(f"命令:{command}")
+    return ";".join(parts)
+
+
+def _render_issue_bucket(title: str, items: list[dict[str, Any]]) -> list[str]:
+    if not items:
+        return [f"- {title}:无。"]
+    return [f"- {title}:{_format_issue_item(item)}" for item in items]
+
+
+def render_user_report_text(report: dict[str, Any]) -> str:
+    status = STATUS_TEXT.get(str(report.get("overall_status") or ""), "未完成")
+    lines = [
+        f"总状态:{status}。",
+        "",
+        "一、产生的文件与完成情况",
+    ]
+    files = report.get("files") or []
+    if files:
+        for item in files:
+            label = str(item.get("label") or "文件")
+            status_text = str(item.get("status") or "unknown")
+            path = str(item.get("path") or "")
+            note = str(item.get("note") or "")
+            suffix = f"({note})" if note else ""
+            path_part = f"{path}:" if path else ""
+            lines.append(f"- {label}:{path_part}{status_text}{suffix}")
+    else:
+        lines.append("- 暂无可确认的产物。")
+
+    issues = report.get("issues") or {}
+    lines.extend(["", "二、过程中遇到的问题与异常耗时"])
+    lines.extend(_render_issue_bucket("已自动处理", list(issues.get("auto_handled") or [])))
+    lines.extend(_render_issue_bucket("建议确认", list(issues.get("needs_confirmation") or [])))
+    lines.extend(_render_issue_bucket("必须处理", list(issues.get("must_handle") or [])))
+    timing = report.get("timing") or {}
+    total_ms = int(timing.get("total_ms") or 0)
+    if total_ms > 0:
+        lines.append(f"- 耗时异常:本次记录耗时约 {total_ms // 1000} 秒。")
+    else:
+        lines.append("- 耗时异常:无记录。")
+    if str(report.get("overall_status") or "") == STATUS_FAILED:
+        lines.append("- 技术详情:如需反馈故障,可附上 `.webnovel/logs/run_last.log`。")
+
+    lines.extend(["", "三、下一步建议"])
+    next_actions = report.get("next_actions") or []
+    if next_actions:
+        for item in next_actions:
+            description = str(item.get("description") or item.get("label") or "").strip()
+            command = str(item.get("command") or "").strip()
+            if command and command != description:
+                lines.append(f"- {description} 可执行:{command}")
+            else:
+                lines.append(f"- {description}")
+    else:
+        lines.append("- 暂无下一步建议;可以运行 `/webnovel-doctor` 查看项目状态。")
+    return "\n".join(lines).rstrip() + "\n"
+
+
+def format_user_report(report: dict[str, Any], output_format: str = "text") -> str:
+    if output_format == "json":
+        return json.dumps(report, ensure_ascii=False, indent=2)
+    return render_user_report_text(report)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Render author-friendly webnovel run report")
+    parser.add_argument("--project-root", required=True, help="书项目根目录")
+    parser.add_argument("--stage", choices=VALID_STAGES, required=True, help="报告阶段")
+    parser.add_argument("--chapter", type=int, default=None, help="目标章节号")
+    parser.add_argument("--volume", type=int, default=None, help="目标卷号")
+    parser.add_argument("--format", choices=VALID_FORMATS, default="text", help="输出格式")
+    args = parser.parse_args()
+
+    report = build_user_report(
+        args.project_root,
+        stage=args.stage,
+        chapter=args.chapter,
+        volume=args.volume,
+    )
+    print(format_user_report(report, args.format))
+
+
+if __name__ == "__main__":
+    main()

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

@@ -289,6 +289,98 @@ def cmd_projections(args: argparse.Namespace) -> int:
     return 0 if report.get("ok") else 1
 
 
+def cmd_user_report(args: argparse.Namespace) -> int:
+    from .user_report import build_user_report, format_user_report
+
+    root = _resolve_root(args.project_root)
+    report = build_user_report(
+        root,
+        stage=args.stage,
+        chapter=args.chapter,
+        volume=args.volume,
+    )
+    print(format_user_report(report, args.format))
+    return 0
+
+
+def cmd_run_ledger(args: argparse.Namespace) -> int:
+    from .run_ledger import (
+        build_write_resume_plan,
+        format_resume_plan,
+        record_write_step,
+    )
+
+    root = _resolve_root(args.project_root)
+    if args.ledger_action == "record-write-step":
+        try:
+            inputs = json.loads(args.inputs_json)
+            outputs = json.loads(args.outputs_json)
+            problems = json.loads(args.problems_json)
+            auto_handled = json.loads(args.auto_handled_json)
+        except json.JSONDecodeError as exc:
+            print(f"ledger JSON 参数不合法: {exc}", file=sys.stderr)
+            return 2
+        if not isinstance(inputs, dict) or not isinstance(outputs, dict):
+            print("inputs-json / outputs-json 必须是 JSON object", file=sys.stderr)
+            return 2
+        if not isinstance(problems, list) or not isinstance(auto_handled, list):
+            print("problems-json / auto-handled-json 必须是 JSON list", file=sys.stderr)
+            return 2
+        entry = record_write_step(
+            root,
+            chapter=args.chapter,
+            step=args.step,
+            status=args.status,
+            mode=args.mode,
+            inputs={str(key): str(value) for key, value in inputs.items()},
+            outputs={str(key): str(value) for key, value in outputs.items()},
+            problems=[str(item) for item in problems],
+            auto_handled=[str(item) for item in auto_handled],
+            duration_ms=args.duration_ms,
+        )
+        if args.format == "json":
+            print(json.dumps(entry, ensure_ascii=False, indent=2))
+        else:
+            print(f"{entry['step']}: {entry['status']}")
+        return 0
+    if args.ledger_action == "write-resume":
+        report = build_write_resume_plan(
+            root,
+            chapter=args.chapter,
+            mode=args.mode,
+        )
+        print(format_resume_plan(report, args.format))
+        return 0
+    return 2
+
+
+def cmd_run_log(args: argparse.Namespace) -> int:
+    from .run_logger import write_run_log
+
+    try:
+        root = _resolve_root(args.project_root)
+    except FileNotFoundError:
+        root = normalize_windows_path(args.project_root).expanduser()
+        try:
+            root = root.resolve()
+        except Exception:
+            root = root
+    try:
+        payload = json.loads(args.payload_json)
+    except json.JSONDecodeError as exc:
+        print(f"payload-json 不是合法 JSON: {exc}", file=sys.stderr)
+        return 2
+    if not isinstance(payload, dict):
+        print("payload-json 必须是 JSON object", file=sys.stderr)
+        return 2
+    result = write_run_log(root, event=args.event, payload=payload, append=args.append)
+    if args.format == "json":
+        print(json.dumps(result, ensure_ascii=False, indent=2))
+    else:
+        print(result["path"])
+    return 0
+
+
 def cmd_use(args: argparse.Namespace) -> int:
     project_root = normalize_windows_path(args.project_root).expanduser()
     try:
@@ -367,6 +459,40 @@ def main() -> None:
     p_projection_replay.add_argument("--format", choices=["json", "text"], default="json", help="输出格式")
     p_projection_replay.set_defaults(func=cmd_projections)
 
+    p_user_report = sub.add_parser("user-report", help="渲染作者友好的最终报告")
+    p_user_report.add_argument("--stage", choices=["init", "plan", "write", "review"], required=True, help="报告阶段")
+    p_user_report.add_argument("--chapter", type=int, default=None, help="目标章节号")
+    p_user_report.add_argument("--volume", type=int, default=None, help="目标卷号")
+    p_user_report.add_argument("--format", choices=["text", "json"], default="text", help="输出格式")
+    p_user_report.set_defaults(func=cmd_user_report)
+
+    p_run_ledger = sub.add_parser("run-ledger", help="记录或查询写章断点续跑状态")
+    run_ledger_sub = p_run_ledger.add_subparsers(dest="ledger_action", required=True)
+    p_record_write_step = run_ledger_sub.add_parser("record-write-step", help="记录写章步骤状态")
+    p_record_write_step.add_argument("--chapter", type=int, required=True, help="目标章节号")
+    p_record_write_step.add_argument("--step", choices=["draft", "review", "data", "commit", "projection", "backup"], required=True)
+    p_record_write_step.add_argument("--status", required=True)
+    p_record_write_step.add_argument("--mode", default="default")
+    p_record_write_step.add_argument("--inputs-json", default="{}")
+    p_record_write_step.add_argument("--outputs-json", default="{}")
+    p_record_write_step.add_argument("--problems-json", default="[]")
+    p_record_write_step.add_argument("--auto-handled-json", default="[]")
+    p_record_write_step.add_argument("--duration-ms", type=int, default=0)
+    p_record_write_step.add_argument("--format", choices=["json", "text"], default="json", help="输出格式")
+    p_record_write_step.set_defaults(func=cmd_run_ledger)
+    p_write_resume = run_ledger_sub.add_parser("write-resume", help="输出写章断点续跑建议")
+    p_write_resume.add_argument("--chapter", type=int, required=True, help="目标章节号")
+    p_write_resume.add_argument("--mode", default="default", help="写章模式")
+    p_write_resume.add_argument("--format", choices=["json", "text"], default="json", help="输出格式")
+    p_write_resume.set_defaults(func=cmd_run_ledger)
+
+    p_run_log = sub.add_parser("run-log", help="写入脱敏运行日志")
+    p_run_log.add_argument("--event", required=True, help="事件名")
+    p_run_log.add_argument("--payload-json", default="{}", help="要写入日志的 JSON 对象")
+    p_run_log.add_argument("--append", action="store_true", help="追加而不是覆盖 run_last.log")
+    p_run_log.add_argument("--format", choices=["json", "text"], default="json", help="输出格式")
+    p_run_log.set_defaults(func=cmd_run_log)
+
     p_use = sub.add_parser("use", help="绑定当前工作区使用的书项目(写入指针/registry)")
     p_use.add_argument("project_root", help="书项目根目录(必须包含 .webnovel/state.json)")
     p_use.add_argument("--workspace-root", help="工作区根目录(可选;默认由运行环境推断)")

+ 147 - 0
webnovel-writer/scripts/run_behavior_evals.py

@@ -230,6 +230,152 @@ def _eval_dashboard_read_only(root: Path, case: dict[str, Any]) -> dict[str, Any
     )
 
 
+def _write_json(path: Path, payload: dict[str, Any]) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def _make_report_project(project_root: Path) -> None:
+    for rel in (
+        ".webnovel/tmp",
+        ".webnovel/backups",
+        ".story-system/commits",
+        "正文",
+        "审查报告",
+    ):
+        (project_root / rel).mkdir(parents=True, exist_ok=True)
+    _write_json(
+        project_root / ".webnovel" / "state.json",
+        {"project_info": {"title": "测试书", "genre": "玄幻"}, "progress": {"current_chapter": 0}},
+    )
+
+
+def _write_report_artifacts(project_root: Path, *, chapter: int = 1, review_skipped: bool = False, blocking: bool = False) -> None:
+    issues = []
+    if blocking:
+        issues.append(
+            {
+                "severity": "critical",
+                "category": "timeline",
+                "location": "第2段",
+                "description": "时间线断裂",
+                "fix_hint": "补过渡",
+                "blocking": True,
+            }
+        )
+    review = {
+        "chapter": chapter,
+        "issues": issues,
+        "issues_count": len(issues),
+        "blocking_count": 1 if blocking else 0,
+        "has_blocking": bool(blocking),
+        "summary": "minimal mode: reviewer skipped" if review_skipped else "ok",
+    }
+    if review_skipped:
+        review["review_skipped"] = True
+        review["review_mode"] = "minimal"
+    _write_json(project_root / ".webnovel" / "tmp" / "review_results.json", review)
+    _write_json(
+        project_root / ".webnovel" / "tmp" / "review_metrics.json",
+        {
+            "start_chapter": chapter,
+            "end_chapter": chapter,
+            "issues_count": len(issues),
+            "blocking_count": 1 if blocking else 0,
+            "report_file": f"审查报告/第{chapter}章审查报告.md",
+        },
+    )
+    (project_root / "审查报告" / f"第{chapter}章审查报告.md").write_text("# 审查报告\n", encoding="utf-8")
+    _write_json(
+        project_root / ".webnovel" / "tmp" / "fulfillment_result.json",
+        {"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
+    )
+    _write_json(project_root / ".webnovel" / "tmp" / "disambiguation_result.json", {"pending": []})
+    _write_json(
+        project_root / ".webnovel" / "tmp" / "extraction_result.json",
+        {"accepted_events": [], "state_deltas": [], "entity_deltas": [], "summary_text": "摘要"},
+    )
+
+
+def _commit_payload(*, chapter: int = 1, status: str = "accepted", projection_status: dict[str, str] | None = None) -> dict[str, Any]:
+    return {
+        "meta": {"chapter": chapter, "status": status},
+        "review_result": {"blocking_count": 0},
+        "fulfillment_result": {"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []},
+        "disambiguation_result": {"pending": []},
+        "extraction_result": {"accepted_events": [], "state_deltas": [], "entity_deltas": [], "summary_text": "摘要"},
+        "projection_status": projection_status
+        or {"state": "done", "index": "skipped", "summary": "skipped", "memory": "skipped", "vector": "skipped"},
+    }
+
+
+def _eval_user_report_probe(root: Path, case: dict[str, Any]) -> dict[str, Any]:
+    scripts_dir = _plugin_root(root) / "scripts"
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+    from data_modules.projection_log import append_projection_run
+    from data_modules.user_report import build_user_report, render_user_report_text
+
+    scenario = str(case.get("scenario") or "")
+    with tempfile.TemporaryDirectory() as tmp:
+        project_root = Path(tmp)
+        _make_report_project(project_root)
+        chapter_file = project_root / "正文" / "第0001章.md"
+        chapter_file.write_text("正文\n", encoding="utf-8")
+        _write_report_artifacts(project_root, chapter=1)
+        commit_path = project_root / ".story-system" / "commits" / "chapter_001.commit.json"
+
+        if scenario == "minimal_review_skipped":
+            _write_report_artifacts(project_root, chapter=1, review_skipped=True)
+            _write_json(commit_path, _commit_payload())
+            (project_root / ".webnovel" / "backups" / "ch0001_ok").mkdir(parents=True, exist_ok=True)
+            report = build_user_report(project_root, stage="write", chapter=1)
+            text = render_user_report_text(report)
+            ok = report["overall_status"] == "partial" and "review_skipped" in json.dumps(report, ensure_ascii=False) and "minimal" in text
+            evidence = [report["overall_status"], text]
+        elif scenario == "missing_data_artifacts":
+            for rel in ("fulfillment_result.json", "disambiguation_result.json", "extraction_result.json"):
+                path = project_root / ".webnovel" / "tmp" / rel
+                if path.exists():
+                    path.unlink()
+            report = build_user_report(project_root, stage="write", chapter=1)
+            ok = report["overall_status"] != "completed" and bool(report["issues"]["must_handle"])
+            evidence = [report["overall_status"], json.dumps(report["issues"], ensure_ascii=False)]
+        elif scenario == "projection_retry_auto_handled":
+            failed_payload = _commit_payload(projection_status={"state": "done", "index": "failed:locked", "summary": "skipped", "memory": "skipped", "vector": "skipped"})
+            _write_json(commit_path, failed_payload)
+            append_projection_run(project_root, failed_payload, {"index": {"status": "failed:locked"}}, commit_path=commit_path)
+            append_projection_run(
+                project_root,
+                failed_payload,
+                {
+                    "state": {"status": "done"},
+                    "index": {"status": "skipped"},
+                    "summary": {"status": "skipped"},
+                    "memory": {"status": "skipped"},
+                    "vector": {"status": "skipped"},
+                },
+                commit_path=commit_path,
+            )
+            (project_root / ".webnovel" / "backups" / "ch0001_ok").mkdir(parents=True, exist_ok=True)
+            report = build_user_report(project_root, stage="write", chapter=1)
+            ok = any(item.get("code") == "projection retry" for item in report["issues"]["auto_handled"]) and not report["issues"]["must_handle"]
+            evidence = [json.dumps(report["issues"], ensure_ascii=False)]
+        elif scenario == "review_blocking_must_handle":
+            _write_report_artifacts(project_root, chapter=1, blocking=True)
+            report = build_user_report(project_root, stage="review", chapter=1)
+            ok = report["overall_status"] == "needs_user" and any(item.get("code") == "blocking_review" for item in report["issues"]["must_handle"])
+            evidence = [json.dumps(report["issues"], ensure_ascii=False)]
+        else:
+            return _result(case, passed=False, reason=f"unknown user_report scenario: {scenario}")
+    return _result(
+        case,
+        passed=ok,
+        reason=f"user-report probe {scenario} passed" if ok else f"user-report probe {scenario} failed",
+        evidence=evidence,
+    )
+
+
 EVALUATORS = {
     "skill_frontmatter": _eval_skill_frontmatter,
     "skill_contract": _eval_skill_contract,
@@ -238,6 +384,7 @@ EVALUATORS = {
     "artifact_ownership": _eval_artifact_ownership,
     "commit_projection_runtime": _eval_commit_projection_runtime,
     "dashboard_read_only": _eval_dashboard_read_only,
+    "user_report_probe": _eval_user_report_probe,
 }
 
 

+ 23 - 0
webnovel-writer/skills/webnovel-init/SKILL.md

@@ -244,6 +244,29 @@ test "$(basename "${PROJECT_ROOT}")" = "${PROJECT_SLUG}"
 
 恢复:只补缺失字段,不全量重问;只重跑最小步骤(文件缺失→重跑 `webnovel.py init`;总纲缺字段→只 patch 总纲;idea_bank 不一致→只重写该文件);重新验证,全部通过后结束。
 
+## 作者友好过程提示与恢复契约
+
+初始化开始前先说明本次会经历:收集故事核心 -> 确认创意约束 -> 生成项目骨架 -> 写入初始故事档案 -> 验证能否进入规划。过程提示用作者语言,不直接输出原始 JSON、traceback 或长命令日志;技术详情写入 `.webnovel/logs/run_last.log`:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" run-log \
+  --event init-progress \
+  --payload-json "{\"stage\": \"init\"}" \
+  --format text
+```
+
+过程提示每次不超过两行,只说当前动作和影响,例如“正在生成项目骨架:会创建设定集、总纲和初始故事档案”。少打扰确认策略:默认继续收集和生成;只有核心设定、参考拆解采用、项目目录安全、写入 canon 前的最终方案需要用户拍板。
+
+需要用户裁决时使用有限选项,并说明每个选项影响;例如保留当前设定 / 修改局部 / 暂停初始化。卡住时必须说明卡点、已完成内容和恢复建议,例如“设定集已生成,Story System 初始档案缺失;重新运行 `/webnovel-init` 会只补缺失文件”。
+
+不可恢复故障才在最终报告提示 `.webnovel/logs/run_last.log`;平时只保留日志,不打扰作者。收尾必须调用作者报告 helper:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" user-report \
+  --stage init \
+  --format text
+```
+
 ## 作者友好最终报告契约
 
 最终回复必须面向作者,不输出原始 JSON、traceback 或长命令日志。使用固定三段式,并以一句总状态开头:

+ 24 - 0
webnovel-writer/skills/webnovel-plan/SKILL.md

@@ -232,6 +232,30 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" sto
 2. 最后一个批次无效时,只删除并重写该批次。
 3. 仅在全部验证通过后更新状态。
 
+## 作者友好过程提示与恢复契约
+
+规划开始前先说明本次会经历:检查总纲与设定 -> 生成节拍表 -> 生成时间线 -> 拆章纲 -> 写回新增设定 -> 刷新写作合同。过程提示用作者语言,不直接输出原始 JSON、traceback 或长命令日志;技术详情写入 `.webnovel/logs/run_last.log`:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" run-log \
+  --event plan-progress \
+  --payload-json "{\"stage\": \"plan\", \"volume\": {volume_id}}" \
+  --format text
+```
+
+过程提示每次不超过两行,只说当前动作和影响,例如“正在拆本卷章纲:会把每章目标、时间锚点和禁区写清楚”。少打扰确认策略:默认继续推进;只有总纲 / 设定冲突、时间线回跳、卷末钩子取舍、需要覆盖已有规划时才询问。
+
+需要用户裁决时使用有限选项,并说明影响;例如沿用总纲 / 修改设定 / 暂停规划。卡住时必须说明卡点、已完成内容和恢复建议,例如“节拍表和时间线已保留,第 21-30 章拆分失败;重新运行 `/webnovel-plan {volume_id}` 会只重做失败批次”。
+
+不可恢复故障才在最终报告提示 `.webnovel/logs/run_last.log`;平时只保留日志,不打扰作者。收尾必须调用作者报告 helper:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" user-report \
+  --stage plan \
+  --volume {volume_id} \
+  --format text
+```
+
 ## 作者友好最终报告契约
 
 最终回复必须面向作者,不输出原始 JSON、traceback 或长命令日志。使用固定三段式,并以一句总状态开头:

+ 24 - 0
webnovel-writer/skills/webnovel-review/SKILL.md

@@ -126,6 +126,30 @@ python "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" update-stat
 4. 审查记录已写入 `.webnovel/state.json` 兼容投影。
 5. 存在阻断问题时,用户已明确选择处理策略。
 
+## 作者友好过程提示与恢复契约
+
+审查开始前先说明本次会经历:定位待审正文 -> 刷新缺失合同 -> 写作检查 -> 生成报告和指标 -> 处理阻断裁决。过程提示用作者语言,不直接输出原始 JSON、traceback 或长命令日志;技术详情写入 `.webnovel/logs/run_last.log`:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" run-log \
+  --event review-progress \
+  --payload-json "{\"stage\": \"review\", \"chapter\": {chapter_num}}" \
+  --format text
+```
+
+过程提示每次不超过两行,只说当前动作和影响,例如“正在生成审查报告:会把阻断问题和最值得改的建议放到顶部”。少打扰确认策略:无阻断时不询问;存在 blocking issue、缺待审正文、用户要求是否立即修改时才询问。
+
+需要用户裁决时使用有限选项,并说明影响;例如立即修复 / 仅保存报告稍后处理 / 放弃本次审查。卡住时必须说明卡点、已完成内容和恢复建议,例如“reviewer 结果已保存,metrics 落库失败;重新运行 `/webnovel-review {chapter_num}` 会从报告落库继续”。
+
+不可恢复故障才在最终报告提示 `.webnovel/logs/run_last.log`;平时只保留日志,不打扰作者。收尾必须调用作者报告 helper:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" user-report \
+  --stage review \
+  --chapter {chapter_num} \
+  --format text
+```
+
 ## 作者友好最终报告契约
 
 最终回复必须面向作者,不输出原始 JSON、traceback 或长命令日志。使用固定三段式,并以一句总状态开头:

+ 46 - 0
webnovel-writer/skills/webnovel-write/SKILL.md

@@ -274,6 +274,52 @@ python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" bac
 
 备份必须以解析后的 `PROJECT_ROOT` 为准,禁止从工作区父目录执行裸全量 Git add,避免把书项目仓库作为父仓库的嵌入仓库/submodule 加入。
 
+## 作者友好过程提示与恢复契约
+
+开始写章前先用作者语言说明本次目标、主要阶段和是否需要守在旁边,不承诺固定耗时。过程提示只说当前在做什么和会产生什么,不直接输出原始 JSON、traceback 或长命令日志;技术详情写入 `.webnovel/logs/run_last.log`:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" run-log \
+  --event write-start \
+  --payload-json "{\"chapter\": {chapter_num}, \"mode\": \"{mode}\"}" \
+  --format text
+```
+
+写章过程节点(最多 6 个):
+
+1. 检查项目环境:确认项目、占位符和本章要求可用。
+2. 整理写作依据:读取章纲、最近剧情和未回收伏笔。
+3. 起草正文:根据写作任务书生成本章正文。
+4. 写作检查:审查阻断问题和高收益修改建议。
+5. 保存本章故事事实:提取本章目标完成情况、歧义和新事实。
+6. 提交备份:把本章事实入账、更新故事资料并备份。
+
+重复执行同一章时,先读取可信断点:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" run-ledger write-resume \
+  --chapter {chapter_num} \
+  --mode "{mode}" \
+  --format json
+```
+
+`run-ledger write-resume` 只给续跑建议,不自动覆盖文件。它会根据正文、审查结果、data artifacts、commit、projection 和备份状态判断从哪里继续。正文被手动改过、章纲更新晚于正文、本章已 accepted 又重跑时,必须停下用有限选项询问:沿用当前正文 / 重新起草 / 只查看状态;不得覆盖作者手改。
+
+每个关键步骤完成后记录 `run-ledger record-write-step`,至少记录 step、status、输入/输出文件路径、problems、auto_handled 和 duration_ms,供下一次续跑和最终报告使用。
+
+少打扰确认策略:默认继续推进;只有创作方向、事实一致性、文件覆盖风险或 blocking issue 无法定点处理时才问。需要用户裁决时给 2-3 个有限选项,并说明每个选项影响。
+
+卡住时必须说明卡点、已完成内容和恢复建议:例如“正文和审查报告已保留,保存本章故事事实失败;重新运行 `/webnovel-write {chapter_num}` 会从 data-agent 继续”。不可恢复故障才在最终报告提示 `.webnovel/logs/run_last.log`;平时只保留日志,不打扰作者。
+
+收尾必须调用作者报告 helper,优先以 helper 输出组织最终回复:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" user-report \
+  --stage write \
+  --chapter {chapter_num} \
+  --format text
+```
+
 ## 充分性闸门
 
 1. 正文文件存在且非空