Quellcode durchsuchen

Merge fix/audit-2026-06-10: v6 final data-safety maintenance (Phase 0 + Task 7)

lingfengQAQ vor 1 Woche
Ursprung
Commit
22ab41554c

+ 388 - 0
docs/architecture/story-repo-spec-2026-06-10.md

@@ -0,0 +1,388 @@
+# Story Repo 格式规格(v7 草案 0.4 · 已冻结)
+
+> 状态:0.4。相对 0.3 的变更:伞目录「定稿/」改名「定稿/」(作者拍板,贴作者心智:草稿在工作区,验收后入定稿)。相对 0.2 的变更:废除大一统 YAML 数据文件,承诺与信息差改为"每条一个 Markdown + 平铺 front matter"(§4.3、§5);新增防呆方言(§2.2)、修复卡(§10 第 0 态)、"对话即编辑器"不变量(§1.9)。决策记录见 §14。
+> 来源:2026-06-10「最正确架构」讨论 + 绞杀式收敛计划 Phase 0。
+> 地位:本规格是 v7 的法律文本。代码是格式的派生物——任何实现与本规格冲突时,改实现或改规格,不允许"代码里悄悄多存一份"。
+
+---
+
+## 0. 一句话
+
+一本书 = 一个 git 仓库。`定稿/` 存已接受的事实与正文,`大纲/` 存作者意图与承诺,`文风/` 存品味,`工作区/` 存棘轮之前的一切。接受一章 = 一次原子 commit。除一个可随时删除的缓存外,不存在任何持久派生物。
+
+## 1. 设计不变量(法律条款)
+
+1. **文件即真相**:全部状态是人可读、敢手改的 Markdown/YAML。系统检测手改并提议结转,永不报错拒绝。
+2. **派生物可丢弃**:删光 `.cache/` 后,系统从源文件全量重建,所有查询照常回答。这是 CI 验收项。
+3. **定稿只增不改**:已接受章节的事实不可逆改。错别字修正例外;真要改设定走显式 retcon 事务(§9)。
+4. **结转原子性**:章事务的 settle 要么完成一次 commit,要么工作区原样保留,没有中间态。
+5. **写评分离**:渲染与评审必须在不同上下文中进行;机检先于模型评审。
+6. **升级原则**:脚本 < 模型 < 作者。能数的不让模型估,能模型判的不打扰作者;作者的界面单位是决策卡,不是命令。
+7. **不在 VCS 里再造 VCS**:版本、审计、分支、回溯一律用 git,不自建提交链。
+8. **容错读取**:解析任何源文件遇到未知字段,必须保留并原样写回。开源使用者会扩展格式,工具不得丢数据。
+9. **对话即编辑器**:任何结构化数据的修改都可以用一句自然语言完成("把 P-031 弃了,理由是改走暗线")。作者无须学习任何文件格式;手改文件永远只是逃生门。
+
+## 2. 目录总览(全中文)
+
+使用者全部是中文网文作者,**目录结构、文件名、作者可见的字段名一律中文**。ASCII 只保留给机器协议:条目编号(P-031、S-021)、commit 前缀(ch/vol/retcon/fix)、`spec_version`、技术文件(`book.yaml`、`.gitignore`、`.cache/`)。
+
+```text
+<书名>/
+├── .git/
+├── .gitignore                 # 至少含 .cache/ 与 工作区/
+├── book.yaml                  # 全书配置(§3)
+├── 定稿/                       # ── 棘轮之后 ──
+│   ├── 正文/
+│   │   └── 0152-北境的雪.md    # 章节 + front matter(§4.1)
+│   ├── 设定/
+│   │   ├── 角色/
+│   │   │   └── 林晚.md         # 角色卡(§4.2)
+│   │   ├── 信息差/
+│   │   │   └── S-021-灭门真凶.md  # 信息差,每条一文件(§4.3)
+│   │   ├── 世界观.md           # 世界规则、力量体系
+│   │   ├── 时间线.md           # append-only 表(§4.4)
+│   │   └── 名册.md             # 实体名册:正名/别名/首现章(§4.5)
+│   └── 记忆/
+│       ├── 章摘要/0152.md      # settle 时入册(§4.6)
+│       └── 卷摘要/第05卷.md    # 卷复盘时生成
+├── 大纲/                       # ── 作者意图,可变 ──
+│   ├── 总纲.md                 # 主题、金手指、结局承诺
+│   ├── 卷纲/
+│   │   └── 第05卷.md
+│   └── 承诺/
+│       └── P-031-灭门真凶.md   # 承诺,每条一文件(§5)
+├── 文风/                       # ── 品味库 ──
+│   ├── 风格宪法.md             # (§6.1)
+│   └── 金句库/                 # 自动收割(§6.2)
+├── 工作区/                     # ── 棘轮之前,默认不入 git ──
+│   ├── 决策卡.md               # (§7)
+│   ├── 上下文包.md             # 本次渲染的上下文包,留档供审计
+│   ├── 草稿-A.md               # best-of-N 时 A/B/C
+│   └── 评审报告/               # 机检与镜头评审输出
+└── .cache/                     # 唯一允许的派生缓存(§11)
+    └── index.db
+```
+
+### 2.1 中文路径的工程约束(规范性,CI 强制)
+
+中文路径的编码税通过以下硬约束消化,不靠运气:
+
+- 一切文件 IO 显式 `encoding='utf-8'`;脚本与子进程入口统一注入 `PYTHONUTF8=1`;禁止依赖系统 locale。
+- 仓库初始化时设置 `git config core.quotepath false`(git log 里中文路径可读)。
+- 文件排序一律靠零填充数字前缀(`0152-`、`第05卷`、`P-031`),不依赖中文字典序。
+- 实体引用一律用**正名**(中文),别名经名册(§4.5)解析;不引入 ASCII 实体 id。
+- CI 必须包含 Windows 上中文路径的全链路测试(建库→写章→结转→重建缓存)。
+
+### 2.2 防呆方言(系统写出格式的规范性约束)
+
+非程序员作者手改 YAML 的三大手滑是全角标点、缩进、类型惊喜。系统写出的一切结构化内容必须遵守以下方言——作者照着系统写的样子改,就不容易错:
+
+- **一律平铺**:front matter 与 `book.yaml` 禁止嵌套映射,字段全部顶层。
+- **列表一律块格式**(一行一条 `- 晚晚`),禁止行内 `[a, b]`——块格式天然免疫全角逗号。
+- **危险值加引号**:写出时凡可能被 YAML 误判类型的字符串(纯数字形、true/false/yes/no/是/否 形)自动加引号。
+- 缩进统一两空格;编码 UTF-8 无 BOM。
+- 多条记录**永远每条一个文件**,禁止大一统数据文件——改坏一条只废一条,且自由文本归 Markdown 正文(全角标点随便用),front matter 只承载少量平铺短字段。
+
+## 3. book.yaml
+
+全仓库唯一的纯 YAML 文件:低频、平铺、十行以内。
+
+```yaml
+spec_version: "7.0"
+书名: 示例书名
+类型: 玄幻
+每章目标字数: 3000
+卷规模: 40                 # 参考值,不是硬约束
+文体基线起: 1              # 文体指纹基线章区间,作者可改
+文体基线止: 30
+高承诺最大搁置章数: 10     # 超过亮"节奏债"
+连续弱钩上限: 3
+关键章稿数: 3              # 卷首/转折/高潮章 best-of-N
+```
+
+## 4. 定稿/
+
+草稿在工作区,验收后入定稿——目录名即心智模型。
+
+### 4.1 章节文件 `定稿/正文/NNNN-标题.md`
+
+文件名:四位零填充章号 + 短横 + 标题。front matter 携带本章合同与结转摘要——**章节自带自己的验收记录**,审计不依赖外部表。本文件仅由 settle 写出(作者改的是正文,不是 front matter)。
+
+```markdown
+---
+章号: 152
+标题: 北境的雪
+卷: 5
+视角: 林晚
+书内时间: 大历1023年冬月初三    # 自由文本,全书格式一致
+字数: 3120
+开启承诺:
+  - P-058
+推进承诺:
+  - P-031
+  - P-007
+兑付承诺:
+  - P-019
+合同:                           # 接受时点的合同断言(已验收)
+  - 林晚得知灭门真相的第一条实证
+  - P-058 开启:神秘老者来历
+  - 结尾强钩:玄阶令牌现世
+---
+正文……
+```
+
+### 4.2 角色卡 `定稿/设定/角色/<正名>.md`
+
+front matter 是机检消费的结构化字段(平铺、块列表);正文是自由设定文本。
+
+```markdown
+---
+姓名: 林晚
+别名:
+  - 晚晚
+  - 林师妹
+状态: 在世                # 在世/已死/失踪/封印…
+位置: 北境雪原            # 最后已知位置
+境界: 筑基七层            # 量纲见 世界观.md
+持有:
+  - 青霜剑
+  - 玄阶令牌
+最后变更章: 152
+---
+## 设定
+…自由文本…
+## 关系
+…自由文本,重要关系变化也应反映在时间线…
+```
+
+### 4.3 信息差 `定稿/设定/信息差/<编号>-<短题>.md`
+
+不追踪"每个角色知道哪些事件",只登记**值得管理的信息差**,每条一个文件。泄密机检 = 扫描角色对白/内心戏,比对其不知道的信息差关键词;废笔机检 = 向读者复述"读者已知"的信息。群像戏的"谁在场"需求由时间线的在场列(§4.4)补足,不另建机制。
+
+```markdown
+---
+知情人:
+  - 大长老
+  - 神秘老者
+读者已知: false
+登记章: 87
+关键词:                   # 泄密扫描用
+  - 大长老
+  - 灭门
+  - 血书
+---
+## 内容
+灭门真凶是大长老。当年血案的执行者是其门下死士。
+```
+
+### 4.4 时间线 `定稿/设定/时间线.md`
+
+append-only 的 Markdown 表,settle 时追加一行。**在场列可空**:日常章不用填,群像戏、密谋戏建议填——它是后续查"谁见过什么"的唯一数据源。
+
+```markdown
+| 章 | 书内时间 | 一句话事件 | 在场 |
+|----|----------|------------|------|
+| 152 | 1023冬月初三 | 林晚于北境得血书,玄阶令牌现世 | 林晚, 神秘老者 |
+```
+
+### 4.5 实体名册 `定稿/设定/名册.md`
+
+防"同人异名/同名异人"。表:`| 正名 | 别名 | 类型 | 首现章 |`。机检在 settle 时比对正文新专名与名册,未登记的专名列入验收卡请作者确认(新实体 or 笔误)。
+
+### 4.6 记忆层 `定稿/记忆/`
+
+- `章摘要/NNNN.md`:≤200 字,settle 前由模型生成、**放进验收卡供作者扫一眼**(可顺手改),随事务入册。
+- `卷摘要/第NN卷.md`:≤500 字,卷复盘时生成:主线推进、关系变化、未兑付承诺清单。
+- 更长程的"全书骨架"是**派生物**:由卷摘要按需拼接压缩,不落盘。
+
+## 5. 承诺 `大纲/承诺/`(每条一文件)
+
+系统的心脏。伏笔、悬念、感情线、爽点预期、立旗统一为"对读者的承诺"。文件名 `P-031-灭门真凶.md`(编号-短题);front matter 只有六个平铺短字段,描述、兑付计划、履历全部是 Markdown 正文——作者爱怎么写怎么写。
+
+```markdown
+---
+类型: 悬念                # 伏笔|悬念|冲突|关系线|立旗|爽点铺垫
+强度: 高                  # 高|中|低
+状态: 进行                # 进行|已兑付|已弃置
+开启章: 12
+兑付期限: 第7卷           # 卷号或章号;强度为"高"时必填
+最后推进章: 152
+---
+## 描述
+灭门真凶的身份。
+
+## 兑付计划
+第七卷宗门大会当众揭穿。开启时必填,防悬空。
+
+## 履历
+- 第12章:开启
+- 第87章:推进——血书线索现世
+- 第152章:推进——林晚取得实证
+```
+
+**结转规则(脚本执行,机检级)**:
+
+- 每章 settle 必须 touch(开启/推进/兑付)至少一条承诺,否则打回。**豁免权在决策卡**:作者拍板 brief 时可勾"本章豁免承诺结转"(理由必填,如纯番外);模型不能自行豁免。
+- settle 对承诺文件的写入 = 更新 front matter(状态/最后推进章)+ 在履历追加一行。
+- 开新承诺必须有兑付计划;强度"高"必须有兑付期限。
+- 节奏债 = 强度"高"且 `当前章 − 最后推进章 > 高承诺最大搁置章数`,亮黄灯进决策卡。
+- 弃置必须在履历中留原因行(作者决定弃坑也要留痕)。
+- 账本级视图(到期清单、节奏债、统计)由 `.cache/` 与盘面提供,**不维护任何汇总文件**。
+- 派生指标(不另建机制):卡点强度 = 章尾未兑付承诺的强度;追读风险 = 节奏债数量 + 连续弱钩计数。
+
+## 6. 文风/
+
+### 6.1 风格宪法 `文风/风格宪法.md`
+
+系统对作者品味的全部认知,作者可审可改。front matter 为机器消费部分(块列表;口癖按"角色:词条"平铺为列表行,不嵌套):
+
+```markdown
+---
+禁词:
+  - 眸子一缩
+  - 嘴角勾起一抹
+禁句式:
+  - '不是.*而是'
+口癖:
+  - 林晚:自称"本姑娘"
+---
+## 铁律
+…
+## 节奏偏好
+…
+## 来自否决的规则
+- 不要用天气开篇(出处:第 89/103/121 章三次否决)
+```
+
+规则入宪走"三次同类否决→系统提议→作者确认"的流程,每条带出处。
+
+### 6.2 金句库 `文风/金句库/`
+
+settle 时若 `diff(模型草稿, 作者终稿)` 超阈值(默认:单段改动 > 30%),自动把"作者改后段落 + 场景标签"存为样本。渲染同类场景时作为 few-shot 注入。按场景分文件:`战斗.md`、`对白.md`、`情感.md`……每文件保留最近 20 条,旧的轮换归档。
+
+## 7. 工作区/ 与决策卡
+
+工作区默认整体 gitignored——棘轮之前的一切允许丢失;合同的最终归宿是章节 front matter(§4.1)。**想保留草稿史的使用者删掉 `.gitignore` 里那一行即可**:settle 照常清空工作区,系统行为不变,历史由 git 自然留下。这是规范性要求——实现不得假设工作区未被 git 跟踪。
+
+`决策卡.md` 是作者唯一需要看的界面,固定四段:
+
+```markdown
+# 决策卡:第 152 章
+## 盘面(脚本生成)
+- 位置:第 5 卷 24/40 章
+- 节奏债:P-031(高,12 章未推进)
+- 连续弱钩:2 章
+## 提案
+推进 P-031(林晚取得实证)、顺手开 P-058、结尾玄阶令牌现世卡章。
+## 合同(拍板即生效)
+- [ ] 林晚得知灭门真相的第一条实证
+- [ ] P-058 开启:神秘老者来历
+- [ ] 结尾强钩
+## 备选
+B 方案:本章纯感情线过渡,P-031 推到下章(代价:节奏债 +1)
+(如需豁免承诺结转,在此注明理由)
+```
+
+作者动作只有三种:采纳 / 改卡 / 选备选。改卡 = 直接编辑此文件,或一句话让系统改(不变量 9)。
+
+## 8. 章事务(内环)
+
+| # | 阶段 | 执行体 | 文件效果 |
+|---|------|--------|----------|
+| 1 | brief | 脚本读盘面 | 生成 `工作区/决策卡.md` |
+| 2 | 拍板 | **作者** | 决策卡的合同段固化 |
+| 3 | pack | 脚本 | 组装 `工作区/上下文包.md`:盘面+合同+事实切片+信息差边界+近章结尾+反复读清单+风格锚点 |
+| 4 | 渲染 | 模型(干净上下文) | `工作区/草稿-*.md`;关键章 best-of-N |
+| 5 | 机检 | 脚本 | 合同断言比对、泄密扫描(信息差)、禁词/复读、新专名比对名册、字数。**不过关直接打回第 4 步,不打扰作者** |
+| 6 | 镜头评审 | 模型 ×3(各自新鲜上下文) | 读者镜头(爽不爽/哪段想划走)、编辑镜头(结构与商业性)、事实镜头(只解释机检结果)→ `工作区/评审报告/` |
+| 7 | 验收 | **作者** | 验收卡 = 草稿 + 三句话评审 + 待确认新专名 + **章摘要(扫一眼,可改)**。动作:接受 / 改完接受 / 打回 |
+| 8 | settle | 脚本,**原子** | 见下 |
+
+**settle 的一次 commit 包含**:草稿 → `定稿/正文/`(front matter 写入合同与承诺结转);`设定/` 变更(位置/状态/境界/持有/信息差/名册/时间线);涉及的 `大纲/承诺/` 文件结转(front matter 更新 + 履历追加);`记忆/章摘要/`(验收卡定稿版);`文风/金句库/` 收割(如触发);工作区清空。
+
+**commit message 约定**(前缀 ASCII 是机器协议,便于 `git log --grep`):
+
+```text
+ch(152): 北境的雪
+
+承诺: +P-058 ~P-031 ~P-007 $P-019     # + 开启 ~ 推进 $ 兑付
+设定: 林晚.位置=北境雪原; 信息差+S-021
+```
+
+## 9. 中环、外环与例外事务
+
+- **卷复盘** `vol(05): 复盘与下卷规划`:承诺审计(本卷开/收/过期清单)→ `记忆/卷摘要/` → 与作者对谈产出 `大纲/卷纲/第06卷.md` → 顺手做伏笔机会扫描(模型提 3-5 个"本卷可埋、N 卷后响"候选,必须引用总纲的具体远期节点,作者勾选后生成承诺文件)。
+- **体检**(每 50 章自动):文体指纹 vs 文体基线区间的漂移报告 + 承诺坏账 + 时间线孤儿 → 报告进工作区,不入册;作者决定回拉或更新基线。
+- **retcon**:`retcon(87): 修正大长老境界设定`——显式事务,允许改定稿,要求 commit message 写明原因,设定/承诺同步,审计留痕。
+- **手改检测**:每次启动 `git status` 发现定稿/大纲有未结转的手改 → 决策卡问一句"结转吗",确认后 `fix(设定): …` 入册。**系统适应作者,不报错。**
+- **分支未来**:作者想试另一条线 → `git branch what-if/xxx`,各推演 3 章纲要,读完 merge 或删分支。
+
+## 10. 盘面状态机(单入口)
+
+系统启动时按序判定,命中即停:
+
+| 序 | 条件 | 下一步 |
+|----|------|--------|
+| 0 | 任一源文件解析失败 | **修复卡**:定位到行、展示上下文、模型提议保留意图的修复、作者确认。全角冒号/逗号出现在结构位置(键后、列表分隔)属确定性错误,可直接预修复后只报不问。**永不带堆栈崩溃** |
+| 1 | 定稿/大纲有未结转手改 | 提议 fix 结转 |
+| 2 | 工作区有未完成事务 | 从中断的阶段继续 |
+| 3 | 刚结的章是卷末章 | 卷复盘 |
+| 4 | 章号到达体检周期 | 体检卡 |
+| 5 | 其余 | 新章 brief(内环第 1 步) |
+
+8 个旧命令全部内化为以上状态的阶段。作者只需要一个入口和"继续"。
+
+## 11. 派生缓存 `.cache/index.db`
+
+唯一允许的持久派生物,gitignored,任何时刻可删。首查重建,重建器只读 定稿/大纲/文风 源文件。表(机器域,表名英文):`chapters`(front matter 展开)、`promises`、`secrets`、`entities`(名册)、`fingerprints`(文体指纹历史)。**重建器即格式的参考实现**——能完整重建,说明格式自洽。
+
+## 12. 迁移映射(v6 → v7,一次性脚本)
+
+| v6 | v7 |
+|----|----|
+| `正文/` | `定稿/正文/`(补 front matter,合同字段标"迁移") |
+| `设定集/` | `定稿/设定/`(角色卡补 front matter) |
+| `大纲/`(总纲、卷纲) | `大纲/总纲.md`、`大纲/卷纲/` |
+| plot_threads / foreshadowing / chase_debt / reading_power | 逐条生成 `大纲/承诺/` 文件 |
+| `.webnovel/state.json` | 一次性展开进 `定稿/设定/` |
+| `summaries/` | `定稿/记忆/` |
+| project_memory patterns / scratchpad | `文风/风格宪法.md`(人工过一遍再入宪) |
+| `.story-system/` 提交链 | 迁移时压成一个初始 commit,原目录只读归档 |
+| index.db / vectors.db / projection_log | 删除(index 进 `.cache/` 重建;向量为可选插件) |
+
+## 13. 不做清单
+
+- ❌ 大一统 YAML 数据文件(多条记录一律每条一个 Markdown 文件)
+- ❌ 事件日志表与逐事件 witnesses 投影(被 信息差/ + 时间线在场列 取代)
+- ❌ 持久向量库(语义检索 = 可选插件,永不做事实召回主路径)
+- ❌ 常驻服务(Dashboard 改为按需静态简报,只读 story repo)
+- ❌ 自建提交链 / projection_log / scratchpad
+- ❌ 模型自由评"文笔好坏"(镜头职责里明确排除)
+- ❌ 全自动无人值守模式
+
+## 14. 决策记录
+
+### 0.1 → 0.2
+
+| # | 问题 | 决定 | 决策人 | 理由 |
+|---|------|------|--------|------|
+| 1 | 目录语言 | 全中文目录 + 全中文作者可见字段;机器协议保留 ASCII | 作者 | 使用者全是中文网文作者;编码税以 §2.1 硬约束消化 |
+| 2 | 工作区入 git? | 默认不入;删 `.gitignore` 一行即可选入,实现必须兼容两种状态 | Claude | 默认保持"棘轮前可弃"的简洁,复杂需求一行开关满足 |
+| 3 | 信息差简化够用? | 保持轻量登记;时间线加可选"在场"列覆盖群像需求 | Claude | 一列数据 vs 一套事件投影机制,成本差两个量级 |
+| 4 | 章摘要谁拍板 | 进验收卡供作者扫一眼、可改,随事务入册 | 作者 | 摘要是长程记忆的源头,错一条污染后面几百章 |
+| 5 | 每章必须结转承诺 | 保持硬机检;豁免权在决策卡、归作者、理由必填 | Claude | 规则要硬才有意义,但豁免是品味决策,归人不归模型 |
+
+### 0.2 → 0.3(起因:作者质疑"YAML 是否方便作者修改")
+
+| # | 问题 | 决定 | 决策人 | 理由 |
+|---|------|------|--------|------|
+| 6 | 大一统 YAML 对非程序员不安全 | 承诺与信息差拆为"每条一个 Markdown + 平铺 front matter";全仓库唯一纯 YAML 是 book.yaml | 作者提出,Claude 设计 | 全角标点/缩进/类型三类手滑;爆炸半径从全账本缩到单条;自由文本回归正文 |
+| 7 | 手滑预防 | 防呆方言(§2.2):一律平铺、块列表、危险值加引号 | Claude | 作者照系统写出的样子改,不容易错 |
+| 8 | 手滑兜底 | 解析失败出修复卡(§10 第 0 态),不崩溃;全角标点结构性错误确定性预修复 | Claude | 系统里永远有 LLM 在场,这是普通软件没有的兜底 |
+| 9 | 根治路径 | "对话即编辑器"入宪(不变量 9) | Claude | 作者从头到尾不需要学 YAML |
+
+### 0.3 → 0.4
+
+| # | 问题 | 决定 | 决策人 | 理由 |
+|---|------|------|--------|------|
+| 10 | 「正典」译名生硬 | 伞目录改名「定稿/」,结构不变(备选项:顶层拉平、「正史」) | 作者 | 贴作者心智:草稿在工作区,验收后入定稿 |

+ 2 - 0
docs/architecture/narrative-intelligence-roadmap-2026-06-10.md → docs/archive/architecture/narrative-intelligence-roadmap-2026-06-10.md

@@ -1,5 +1,7 @@
 # 叙事智能升级路线图(2026-06-10)
 
+> **状态(2026-06-11 归档):** 本路线图已被 v7 story repo 规格吸收,作为独立计划取消。条目归宿:M1-1 被否决(v7 spec §13,信息差/+时间线在场列取代 witnesses 投影);M1-2 → 承诺派生指标(§5)+ 句式体检;M1-3 → 风格宪法否决入宪(§6.1);M1-4 → 记忆层卷摘要(§4.6);M2-1 → 体检事务(§9);M2-2 → 三镜头评审(§8);M3-1 → 卷复盘伏笔机会扫描(§9);M2-3/M3-2/M3-3 随冻结令搁置。本文"不做清单"中的"禁全自动"已被 spec 0.5 撤销(§8.1),以 spec 为准。见 `docs/architecture/story-repo-spec-2026-06-10.md`。
+
 > 来源:2026-06-10 全项目审查 + 「AI 写长篇网文系统」架构讨论。
 > 前置依赖:`docs/superpowers/plans/2026-06-10-audit-fix-plan.md`(修复计划)完成 Phase 0/1 后启动本路线图;两者改同一批模块,不要并行。
 

+ 29 - 23
docs/superpowers/plans/2026-06-10-audit-fix-plan.md

@@ -1,5 +1,11 @@
 # 2026-06-10 全项目审查修复计划
 
+> **状态(2026-06-11 截断):** 本计划随 v7 绞杀式收敛(`docs/architecture/story-repo-spec-2026-06-10.md`)截断收口。
+> - **已完成并保留**:Phase 0 全部(Task 1-6,正文数据安全)+ Task 7——这是 v6 用户数据与 v7 迁移器读取的地基。
+> - **作废**:Task 8-24(Phase 1 数据链)、Task 26-27、Task 29-34——目标模块(SQLite 投影、event log、v6 提示词、dashboard、CLI 样板)在 v7 中整体删除,不再修缮。
+> - **例外保留为独立候选**:Task 25(嵌入默认出网,隐私问题,若 v6 分支再发维护版则必修)、Task 28(CI 加固,仓库层面,v7 继续复用,可随时单独做)。
+> - 分支 `fix/audit-2026-06-10` 以 Phase 0 + Task 7 收口合入 master,作为 v6 最后一批数据安全维护。
+
 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
 
 **Goal:** 修复 2026-06-10 深度审查发现的全部高/中危问题:数据丢失路径、数据链不一致、skill 流程死锁、隐私出网默认值与守卫绕过。
@@ -22,9 +28,9 @@
 - Modify: `webnovel-writer/scripts/backup_manager.py:150-166, 228-254`
 - Test: `webnovel-writer/scripts/tests/test_backup_manager.py`
 
-- [ ] **Step 1: 写失败测试**:在 tmp git 仓库中故意不配置 `user.name/user.email`(`git config --local --unset` 或 `-c user.useConfigOnly=true`),调用 `backup()`,断言返回失败且输出包含"备份失败"、不产生 `ch{N}` tag。
-- [ ] **Step 2: 运行确认现状是假成功**(当前会打印 ✅ 并打 tag 在旧 HEAD)。
-- [ ] **Step 3: 修复 `_run_git_command`**:`check=False` 分支改为返回 `(result.returncode == 0, stdout, stderr)`;调用方据真实退出码判断。"nothing to commit" 改为从 stdout/stderr 文本判断(当前 `:233` 的 `if not success and "nothing to commit"` 是永假死代码,一并删除重写):
+- [x] **Step 1: 写失败测试**:在 tmp git 仓库中故意不配置 `user.name/user.email`(`git config --local --unset` 或 `-c user.useConfigOnly=true`),调用 `backup()`,断言返回失败且输出包含"备份失败"、不产生 `ch{N}` tag。
+- [x] **Step 2: 运行确认现状是假成功**(当前会打印 ✅ 并打 tag 在旧 HEAD)。
+- [x] **Step 3: 修复 `_run_git_command`**:`check=False` 分支改为返回 `(result.returncode == 0, stdout, stderr)`;调用方据真实退出码判断。"nothing to commit" 改为从 stdout/stderr 文本判断(当前 `:233` 的 `if not success and "nothing to commit"` 是永假死代码,一并删除重写):
 
 ```python
 def _run_git_command(self, args, check=True):
@@ -38,8 +44,8 @@ def _run_git_command(self, args, check=True):
     return ok, result.stdout, result.stderr
 ```
 
-- [ ] **Step 4: `backup()` 中 commit 失败时中止**:不打 tag、返回非零、输出含修复指引(提示运行 `git config user.name/user.email`);"nothing to commit" 视为成功但提示"本章无变更"。
-- [ ] **Step 5: 跑全部 backup 测试通过后提交** `fix: backup reports real git failures and aborts tagging`。
+- [x] **Step 4: `backup()` 中 commit 失败时中止**:不打 tag、返回非零、输出含修复指引(提示运行 `git config user.name/user.email`);"nothing to commit" 视为成功但提示"本章无变更"。
+- [x] **Step 5: 跑全部 backup 测试通过后提交** `fix: backup reports real git failures and aborts tagging`。
 
 ### Task 2: rollback 改为前滚式恢复,去掉 detached HEAD 与硬编码 master
 
@@ -47,8 +53,8 @@ def _run_git_command(self, args, check=True):
 - Modify: `webnovel-writer/scripts/backup_manager.py:294-307`
 - Test: `webnovel-writer/scripts/tests/test_backup_manager.py`
 
-- [ ] **Step 1: 写测试**:建 tmp 仓库(默认分支命名为 `main`),打两个 ch tag,回滚到 ch1 后断言:(a) 仍在原分支(`git symbolic-ref HEAD` 成功且为 main);(b) 工作区内容等于 ch1;(c) `git log` 多出一个"rollback"提交(历史不丢)。
-- [ ] **Step 2: 实现前滚式回滚**:
+- [x] **Step 1: 写测试**:建 tmp 仓库(默认分支命名为 `main`),打两个 ch tag,回滚到 ch1 后断言:(a) 仍在原分支(`git symbolic-ref HEAD` 成功且为 main);(b) 工作区内容等于 ch1;(c) `git log` 多出一个"rollback"提交(历史不丢)。
+- [x] **Step 2: 实现前滚式回滚**:
 
 ```python
 def rollback(self, chapter: int) -> bool:
@@ -66,8 +72,8 @@ def rollback(self, chapter: int) -> bool:
     return True
 ```
 
-- [ ] **Step 3: 删除所有 `checkout master` 硬编码**;任何需要分支名的地方用 `git symbolic-ref --short HEAD` 探测。
-- [ ] **Step 4: 测试通过后提交** `fix: rollback is forward-only, never detaches HEAD`。
+- [x] **Step 3: 删除所有 `checkout master` 硬编码**;任何需要分支名的地方用 `git symbolic-ref --short HEAD` 探测。
+- [x] **Step 4: 测试通过后提交** `fix: rollback is forward-only, never detaches HEAD`。
 
 ### Task 3: 无 Git 时的降级备份必须覆盖正文,或醒目声明没有
 
@@ -75,9 +81,9 @@ def rollback(self, chapter: int) -> bool:
 - Modify: `webnovel-writer/scripts/backup_manager.py:175-195`
 - Test: `webnovel-writer/scripts/tests/test_backup_manager.py`
 
-- [ ] **Step 1: 写测试**:模拟 git 不可用(monkeypatch `_git_available` 为 False),项目含 `正文/第0001章-x.md`,调用 `backup()` 后断言备份目录里存在该正文文件副本。
-- [ ] **Step 2: 实现**:降级路径把 `正文/`、`大纲/`、`设定集/`、`.webnovel/state.json` 全部 `shutil.copytree/copy2` 进 `.webnovel/backups/snapshot_ch{N}_{ts}/`;输出明确列出备份了什么。保留按数量滚动清理(最多 10 个 snapshot)。
-- [ ] **Step 3: 提交** `fix: degraded backup covers manuscript files`。
+- [x] **Step 1: 写测试**:模拟 git 不可用(monkeypatch `_git_available` 为 False),项目含 `正文/第0001章-x.md`,调用 `backup()` 后断言备份目录里存在该正文文件副本。
+- [x] **Step 2: 实现**:降级路径把 `正文/`、`大纲/`、`设定集/`、`.webnovel/state.json` 全部 `shutil.copytree/copy2` 进 `.webnovel/backups/snapshot_ch{N}_{ts}/`;输出明确列出备份了什么。保留按数量滚动清理(最多 10 个 snapshot)。
+- [x] **Step 3: 提交** `fix: degraded backup covers manuscript files`。
 
 ### Task 4: init 重跑不得静默覆盖损坏的 state.json
 
@@ -85,9 +91,9 @@ def rollback(self, chapter: int) -> bool:
 - Modify: `webnovel-writer/scripts/init_project.py:294-300,366`
 - Test: `webnovel-writer/scripts/data_modules/tests/test_init_project_pruning.py`
 
-- [ ] **Step 1: 写测试**:项目里放一个非法 JSON 的 state.json,重跑 init,断言 (a) 生成 `state.corrupt_*.json` 副本且内容等于原损坏文本;(b) 输出包含警告。
-- [ ] **Step 2: 实现**:捕获 `json.JSONDecodeError` 时先 `shutil.copy2(state_path, state_path.with_name(f"state.corrupt_{ts}.json"))` 再重建,打印"⚠️ 原 state.json 已损坏,已另存为 ... 供手工抢救"。
-- [ ] **Step 3: 提交** `fix: preserve corrupt state.json before rebuilding`。
+- [x] **Step 1: 写测试**:项目里放一个非法 JSON 的 state.json,重跑 init,断言 (a) 生成 `state.corrupt_*.json` 副本且内容等于原损坏文本;(b) 输出包含警告。
+- [x] **Step 2: 实现**:捕获 `json.JSONDecodeError` 时先 `shutil.copy2(state_path, state_path.with_name(f"state.corrupt_{ts}.json"))` 再重建,打印"⚠️ 原 state.json 已损坏,已另存为 ... 供手工抢救"。
+- [x] **Step 3: 提交** `fix: preserve corrupt state.json before rebuilding`。
 
 ### Task 5: 迁移脚本带错不精简、写回原子化
 
@@ -95,9 +101,9 @@ def rollback(self, chapter: int) -> bool:
 - Modify: `webnovel-writer/scripts/data_modules/migrate_state_to_sqlite.py:235-258`
 - Test: `webnovel-writer/scripts/data_modules/tests/test_migrate_state_to_sqlite.py`
 
-- [ ] **Step 1: 写测试**:构造一条会迁移失败的实体(如非法类型触发 `stats["errors"] += 1`),跑迁移,断言 state.json 中 `entities_v3` 字段仍在、CLI 退出码非 0。
-- [ ] **Step 2: 实现**:`if stats["errors"]: 跳过步骤5精简,输出"存在迁移错误,已保留原字段"`;步骤 5 的裸 `open('w')+json.dump` 改为 `security_utils.atomic_write_json(state_path, state, use_lock=True)`。
-- [ ] **Step 3: 提交** `fix: migration never prunes state on partial failure`。
+- [x] **Step 1: 写测试**:构造一条会迁移失败的实体(如非法类型触发 `stats["errors"] += 1`),跑迁移,断言 state.json 中 `entities_v3` 字段仍在、CLI 退出码非 0。
+- [x] **Step 2: 实现**:`if stats["errors"]: 跳过步骤5精简,输出"存在迁移错误,已保留原字段"`;步骤 5 的裸 `open('w')+json.dump` 改为 `security_utils.atomic_write_json(state_path, state, use_lock=True)`。
+- [x] **Step 3: 提交** `fix: migration never prunes state on partial failure`。
 
 ### Task 6: archive_manager 原子写 + 恢复顺序反转
 
@@ -105,9 +111,9 @@ def rollback(self, chapter: int) -> bool:
 - Modify: `webnovel-writer/scripts/archive_manager.py:125-128, 494-508`
 - Test: `webnovel-writer/scripts/data_modules/tests/test_archive_manager.py`
 
-- [ ] **Step 1: `save_archive` 改用 `atomic_write_json`**(归档是数据被移出 state 后的唯一副本)。
-- [ ] **Step 2: `restore_character` 顺序反转**:先恢复 SQLite,确认成功后才从归档 JSON 删除该角色;SQLite 失败时归档保持原样并返回错误。写测试:monkeypatch SQLite 恢复抛异常,断言归档文件未被修改。
-- [ ] **Step 3: 提交** `fix: archive writes atomic, restore is delete-last`。
+- [x] **Step 1: `save_archive` 改用 `atomic_write_json`**(归档是数据被移出 state 后的唯一副本)。
+- [x] **Step 2: `restore_character` 顺序反转**:先恢复 SQLite,确认成功后才从归档 JSON 删除该角色;SQLite 失败时归档保持原样并返回错误。写测试:monkeypatch SQLite 恢复抛异常,断言归档文件未被修改。
+- [x] **Step 3: 提交** `fix: archive writes atomic, restore is delete-last`。
 
 ---
 
@@ -119,8 +125,8 @@ def rollback(self, chapter: int) -> bool:
 - Modify: `webnovel-writer/scripts/data_modules/state_manager.py:393-416, 450-451, 606-609`
 - Test: `webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py`
 
-- [ ] `_sync_to_sqlite` 失败时:`save_state` 返回值携带 `sqlite_sync_ok=False`;`process-chapter` CLI 据此 `emit_error`(退出码非 0),错误信息提示运行 `webnovel.py projections retry --chapter N` 补偿。测试:monkeypatch `_sync_pending_patches_to_sqlite` 抛异常,断言 CLI 退出非 0 且 stdout JSON 含补偿指引。
-- [ ] 提交 `fix: surface sqlite sync failures in process-chapter`。
+- [x] `_sync_to_sqlite` 失败时:`save_state` 返回值携带 `sqlite_sync_ok=False`;`process-chapter` CLI 据此 `emit_error`(退出码非 0),错误信息提示运行 `webnovel.py projections retry --chapter N` 补偿。测试:monkeypatch `_sync_pending_patches_to_sqlite` 抛异常,断言 CLI 退出非 0 且 stdout JSON 含补偿指引。
+- [x] 提交 `fix: surface sqlite sync failures in process-chapter`。
 
 ### Task 8: get_state_changes / get_relationships 走 SQLite 回退
 

+ 18 - 13
webnovel-writer/scripts/archive_manager.py

@@ -123,9 +123,8 @@ class ArchiveManager:
             return json.load(f)
 
     def save_archive(self, archive_file, data):
-        """保存归档文件"""
-        with open(archive_file, 'w', encoding='utf-8') as f:
-            json.dump(data, f, ensure_ascii=False, indent=2)
+        """保存归档文件(原子化写入)"""
+        atomic_write_json(archive_file, data, use_lock=True, backup=True)
 
     def check_trigger_conditions(self, state):
         """检查是否需要触发归档"""
@@ -489,23 +488,29 @@ class ArchiveManager:
 
         if not char_to_restore:
             print(f"❌ 归档中未找到角色: {name}")
-            return
-
-        # 移除 archived_at 字段
-        char_to_restore.pop("archived_at", None)
+            return False
 
-        # 原子性修复:先从归档中移除
-        archived = [char for char in archived if char["name"] != name]
-        self.save_archive(self.characters_archive, archived)
+        restored_character = dict(char_to_restore)
+        restored_character.pop("archived_at", None)
 
         # v5.1 引入: 恢复到 SQLite (通过 IndexManager)
-        char_id = char_to_restore.get("id", char_to_restore.get("name", "unknown"))
+        char_id = restored_character.get("id", restored_character.get("name", "unknown"))
         try:
             # 更新实体状态为 active
             self._index_manager.update_entity_field(char_id, "status", "active")
-            print(f"✅ 角色已恢复: {name}")
         except Exception as e:
-            print(f"⚠️ 实体状态恢复失败: {e}")
+            print(f"❌ 实体状态恢复失败,归档已保留: {e}")
+            return False
+
+        archived = [char for char in archived if char.get("name") != name]
+        try:
+            self.save_archive(self.characters_archive, archived)
+        except Exception as e:
+            print(f"❌ 归档更新失败,角色已恢复但归档未删除: {e}")
+            return False
+
+        print(f"✅ 角色已恢复: {name}")
+        return True
 
     def show_stats(self):
         """显示归档统计"""

+ 107 - 73
webnovel-writer/scripts/backup_manager.py

@@ -67,6 +67,11 @@ from project_locator import resolve_project_root
 if sys.platform == "win32":
     enable_windows_utf8_stdio()
 
+
+class BackupError(RuntimeError):
+    """Git backup operation failed."""
+
+
 class GitBackupManager:
     """基于 Git 的备份管理器(支持优雅降级)"""
 
@@ -147,48 +152,77 @@ __pycache__/
             print(f"❌ Git 初始化失败: {e}")
             return False
 
-    def _run_git_command(self, args: List[str], check: bool = True) -> Tuple[bool, str]:
+    def _run_git_command(self, args: List[str], check: bool = True) -> Tuple[bool, str, str]:
         """执行 Git 命令(支持优雅降级)"""
         if not self.git_available:
-            return False, "Git 不可用"
+            return False, "", "Git 不可用"
 
         try:
             result = subprocess.run(
-                ["git"] + args,
+                ["git", *args],
                 cwd=self.project_root,
-                check=check,
                 capture_output=True,
                 text=True,
-                encoding='utf-8',
+                encoding="utf-8",
                 timeout=60
             )
-
-            return True, result.stdout
-
-        except subprocess.CalledProcessError as e:
-            return False, e.stderr
+            ok = result.returncode == 0
+            if check and not ok:
+                message = (result.stderr or result.stdout).strip()
+                raise BackupError(f"git {' '.join(args)} 失败: {message}")
+            return ok, result.stdout, result.stderr
         except subprocess.TimeoutExpired:
-            return False, "Git 命令超时"
+            if check:
+                raise BackupError(f"git {' '.join(args)} 失败: Git 命令超时")
+            return False, "", "Git 命令超时"
         except OSError as e:
-            return False, str(e)
+            if check:
+                raise BackupError(f"git {' '.join(args)} 失败: {e}")
+            return False, "", str(e)
+
+    @staticmethod
+    def _format_git_output(stdout: str, stderr: str) -> str:
+        """合并 Git 输出,优先保留 stderr 中的故障信息。"""
+        return "\n".join(part.strip() for part in (stderr, stdout) if part.strip())
 
     def _local_backup(self, chapter_num: int) -> bool:
         """本地备份(Git 不可用时的降级方案)"""
         backup_dir = self.project_root / ".webnovel" / "backups"
         backup_dir.mkdir(parents=True, exist_ok=True)
 
-        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-        backup_name = f"ch{chapter_num:04d}_{timestamp}"
+        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
+        backup_name = f"snapshot_ch{chapter_num:04d}_{timestamp}"
         backup_path = backup_dir / backup_name
 
         try:
-            # 备份 state.json
+            backup_path.mkdir(parents=True, exist_ok=True)
+            copied = []
+
+            for folder_name in ("正文", "大纲", "设定集"):
+                source_dir = self.project_root / folder_name
+                if source_dir.exists():
+                    shutil.copytree(source_dir, backup_path / folder_name)
+                    copied.append(folder_name)
+
             state_file = self.project_root / ".webnovel" / "state.json"
             if state_file.exists():
-                backup_path.mkdir(parents=True, exist_ok=True)
-                shutil.copy2(state_file, backup_path / "state.json")
+                target_state_dir = backup_path / ".webnovel"
+                target_state_dir.mkdir(parents=True, exist_ok=True)
+                shutil.copy2(state_file, target_state_dir / "state.json")
+                copied.append(".webnovel/state.json")
+
+            snapshots = sorted(
+                (path for path in backup_dir.glob("snapshot_ch*") if path.is_dir()),
+                key=lambda path: path.name,
+            )
+            for old_snapshot in snapshots[:-10]:
+                shutil.rmtree(old_snapshot)
 
             print(f"✅ 本地备份完成: {backup_path}")
+            if copied:
+                print(f"📦 已备份: {', '.join(copied)}")
+            else:
+                print("⚠️  未找到正文/大纲/设定集或 state.json 可备份")
             return True
         except OSError as e:
             print(f"❌ 本地备份失败: {e}")
@@ -209,9 +243,9 @@ __pycache__/
             return self._local_backup(chapter_num)
 
         # Step 1: git add .
-        success, output = self._run_git_command(["add", "."])
+        success, stdout, stderr = self._run_git_command(["add", "."], check=False)
         if not success:
-            print(f"❌ git add 失败: {output}")
+            print(f"❌ 备份失败:git add 失败: {self._format_git_output(stdout, stderr)}")
             return False
 
         # Step 2: git commit
@@ -225,16 +259,20 @@ __pycache__/
             safe_chapter_title = sanitize_commit_message(chapter_title)
             commit_message += f": {safe_chapter_title}"
 
-        success, output = self._run_git_command(
+        success, stdout, stderr = self._run_git_command(
             ["commit", "-m", commit_message],
             check=False  # 允许"无变更"的情况
         )
+        commit_output = self._format_git_output(stdout, stderr)
 
-        if not success and "nothing to commit" in output:
-            print("⚠️  无变更,跳过提交")
+        if not success and "nothing to commit" in commit_output.lower():
+            print("⚠️  本章无变更,跳过提交")
             return True
         elif not success:
-            print(f"❌ git commit 失败: {output}")
+            print(f"❌ 备份失败:git commit 失败")
+            if commit_output:
+                print(commit_output)
+            print("💡 请先运行: git config user.name \"你的名字\" && git config user.email \"you@example.com\"")
             return False
 
         print(f"✅ Git 提交完成: {commit_message}")
@@ -245,9 +283,9 @@ __pycache__/
         # 删除旧 tag(如果存在)
         self._run_git_command(["tag", "-d", tag_name], check=False)
 
-        success, output = self._run_git_command(["tag", tag_name])
+        success, stdout, stderr = self._run_git_command(["tag", tag_name], check=False)
         if not success:
-            print(f"⚠️  创建 tag 失败(非致命): {output}")
+            print(f"⚠️  创建 tag 失败(非致命): {self._format_git_output(stdout, stderr)}")
         else:
             print(f"✅ Git tag 已创建: {tag_name}")
 
@@ -255,56 +293,49 @@ __pycache__/
 
     def rollback(self, chapter_num: int) -> bool:
         """
-        回滚到指定章节(Git checkout)
-
-        ⚠️ 警告:这会丢弃所有未提交的变更!
+        前滚式恢复到指定章节(在当前分支创建恢复提交)
         """
 
         tag_name = f"ch{chapter_num:04d}"
 
         print(f"🔄 正在回滚到第 {chapter_num} 章...")
-        print(f"⚠️  警告:这将丢弃所有未提交的变更!")
-
-        # 检查是否有未提交的变更
-        success, status_output = self._run_git_command(["status", "--porcelain"])
-
-        if status_output.strip():
-            print("\n⚠️  检测到未提交的变更:")
-            print(status_output)
-
-            # 创建备份提交
-            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-            backup_branch = f"backup_before_rollback_{timestamp}"
+        print("💾 将在当前分支创建一个恢复提交,历史不会丢失")
 
-            print(f"\n💾 正在创建备份分支: {backup_branch}")
-
-            success, _ = self._run_git_command(["checkout", "-b", backup_branch])
-            if not success:
-                print("❌ 创建备份分支失败")
-                return False
-
-            success, _ = self._run_git_command(["add", "."])
-            success, _ = self._run_git_command(
-                ["commit", "-m", f"Backup before rollback to chapter {chapter_num}"]
-            )
-
-            print(f"✅ 备份分支已创建: {backup_branch}")
+        success, _, error = self._run_git_command(["rev-parse", "--verify", tag_name], check=False)
+        if not success:
+            print(f"❌ 备份点 {tag_name} 不存在")
+            return False
 
-            # 切换回 master
-            success, _ = self._run_git_command(["checkout", "master"])
+        success, branch, branch_error = self._run_git_command(["symbolic-ref", "--short", "HEAD"], check=False)
+        if not success or not branch.strip():
+            print(f"❌ 当前不在分支上,无法创建前滚恢复提交: {self._format_git_output(branch, branch_error)}")
+            return False
 
-        # 执行回滚
-        success, output = self._run_git_command(["checkout", tag_name])
+        success, stdout, stderr = self._run_git_command(["checkout", tag_name, "--", "."], check=False)
 
         if not success:
-            print(f"❌ 回滚失败: {output}")
+            print(f"❌ 回滚失败: {self._format_git_output(stdout, stderr)}")
             print(f"💡 提示:确保 tag '{tag_name}' 存在(运行 --list 查看所有备份)")
             return False
 
-        print(f"✅ 已回滚到第 {chapter_num} 章!")
+        success, stdout, stderr = self._run_git_command(["add", "-A"], check=False)
+        if not success:
+            print(f"❌ 回滚失败: {self._format_git_output(stdout, stderr)}")
+            return False
+
+        success, stdout, stderr = self._run_git_command(
+            ["commit", "-m", f"rollback: 恢复到 {tag_name} 备份点"],
+            check=False,
+        )
+        commit_output = self._format_git_output(stdout, stderr)
+        if not success and "nothing to commit" not in commit_output.lower():
+            print(f"❌ 回滚提交失败: {commit_output}")
+            return False
+
+        print(f"✅ 已在 {branch.strip()} 分支恢复到第 {chapter_num} 章!")
         print(f"\n💡 提示:")
-        print(f"  - 所有文件(state.json + 正文/*.md)已同步回滚")
-        print(f"  - 如需恢复,运行: git checkout master")
+        print(f"  - 所有文件(state.json + 正文/*.md)已同步恢复")
+        print(f"  - 历史提交保留,可用 git log 查看恢复记录")
 
         return True
 
@@ -316,10 +347,10 @@ __pycache__/
 
         print(f"📊 对比第 {chapter_a} 章 与 第 {chapter_b} 章的差异...\n")
 
-        success, output = self._run_git_command(["diff", tag_a, tag_b, "--stat"])
+        success, output, error = self._run_git_command(["diff", tag_a, tag_b, "--stat"], check=False)
 
         if not success:
-            print(f"❌ 对比失败: {output}")
+            print(f"❌ 对比失败: {self._format_git_output(output, error)}")
             return
 
         print("📈 文件变更统计:")
@@ -327,8 +358,9 @@ __pycache__/
 
         # 显示 state.json 的详细差异
         print("\n📝 state.json 详细差异:")
-        success, state_diff = self._run_git_command(
-            ["diff", tag_a, tag_b, "--", ".webnovel/state.json"]
+        success, state_diff, _ = self._run_git_command(
+            ["diff", tag_a, tag_b, "--", ".webnovel/state.json"],
+            check=False,
         )
 
         if success and state_diff:
@@ -344,7 +376,7 @@ __pycache__/
         print("\n📚 备份列表(Git tags):\n")
 
         # 获取所有 tags
-        success, tags_output = self._run_git_command(["tag", "-l", "ch*"])
+        success, tags_output, _ = self._run_git_command(["tag", "-l", "ch*"], check=False)
 
         if not success or not tags_output:
             print("⚠️  暂无备份")
@@ -357,8 +389,9 @@ __pycache__/
             chapter_num = int(tag[2:])
 
             # 获取该 tag 的提交信息
-            success, commit_info = self._run_git_command(
-                ["log", tag, "-1", "--format=%h %ci %s"]
+            success, commit_info, _ = self._run_git_command(
+                ["log", tag, "-1", "--format=%h %ci %s"],
+                check=False,
             )
 
             if success:
@@ -368,8 +401,9 @@ __pycache__/
 
         # 显示最近 5 次提交
         print("\n📜 最近提交历史:\n")
-        success, log_output = self._run_git_command(
-            ["log", "--oneline", "-5"]
+        success, log_output, _ = self._run_git_command(
+            ["log", "--oneline", "-5"],
+            check=False,
         )
 
         if success:
@@ -383,17 +417,17 @@ __pycache__/
         print(f"🌿 从第 {chapter_num} 章创建分支: {branch_name}")
 
         # 检查 tag 是否存在
-        success, _ = self._run_git_command(["rev-parse", tag_name], check=False)
+        success, _, _ = self._run_git_command(["rev-parse", tag_name], check=False)
 
         if not success:
             print(f"❌ Tag '{tag_name}' 不存在")
             return False
 
         # 创建分支
-        success, output = self._run_git_command(["branch", branch_name, tag_name])
+        success, output, error = self._run_git_command(["branch", branch_name, tag_name], check=False)
 
         if not success:
-            print(f"❌ 创建分支失败: {output}")
+            print(f"❌ 创建分支失败: {self._format_git_output(output, error)}")
             return False
 
         print(f"✅ 分支已创建: {branch_name}")

+ 6 - 3
webnovel-writer/scripts/data_modules/migrate_state_to_sqlite.py

@@ -34,6 +34,7 @@ from typing import Dict, Any, List
 
 from .config import get_config, DataModulesConfig
 from .sql_state_manager import SQLStateManager, EntityData
+from security_utils import atomic_write_json
 
 
 def migrate_state_to_sqlite(
@@ -233,7 +234,10 @@ def migrate_state_to_sqlite(
         print(f"  ✅ 关系: {stats['relationships']} 条")
 
     # 5. 精简 state.json(移除已迁移字段)
-    if not dry_run:
+    if stats["errors"]:
+        if verbose:
+            print("\n⚠️ 存在迁移错误,已保留原字段")
+    elif not dry_run:
         if verbose:
             print(f"\n🔄 精简 state.json...")
 
@@ -254,8 +258,7 @@ def migrate_state_to_sqlite(
             "_migration_timestamp": datetime.now().isoformat()
         }
 
-        with open(state_file, 'w', encoding='utf-8') as f:
-            json.dump(slim_state, f, ensure_ascii=False, indent=2)
+        atomic_write_json(state_file, slim_state, use_lock=True)
 
         new_size = state_file.stat().st_size / 1024
         if verbose:

+ 18 - 4
webnovel-writer/scripts/data_modules/state_manager.py

@@ -226,7 +226,7 @@ class StateManager:
         else:
             self._state = self._ensure_state_schema({})
 
-    def save_state(self):
+    def save_state(self) -> Dict[str, Any]:
         """
         保存状态文件(锁内重读 + 合并 + 原子写入)。
 
@@ -252,7 +252,7 @@ class StateManager:
             ]
         )
         if not has_pending:
-            return
+            return {"saved": False, "sqlite_sync_ok": True}
 
         self.config.ensure_dirs()
 
@@ -415,6 +415,8 @@ class StateManager:
                 else:
                     self._restore_sqlite_pending(sqlite_pending_snapshot)
 
+                return {"saved": True, "sqlite_sync_ok": sqlite_sync_ok}
+
         except filelock.Timeout:
             raise RuntimeError("无法获取 state.json 文件锁,请稍后重试")
 
@@ -453,7 +455,11 @@ class StateManager:
 
         # 方式2: 使用 add_entity/update_entity 收集的增量数据。
         # 数据缓存在 _pending_entity_patches 等变量中。
-        return self._sync_pending_patches_to_sqlite(processed_appearances)
+        try:
+            return self._sync_pending_patches_to_sqlite(processed_appearances)
+        except Exception as exc:
+            logger.warning("SQLite sync failed (pending patches): %s", exc)
+            return False
 
     def _sync_pending_patches_to_sqlite(self, processed_appearances: set = None) -> bool:
         """同步 _pending_entity_patches 等到 SQLite(v5.1 引入,v5.4 沿用)
@@ -1471,7 +1477,15 @@ def main():
             return
 
         warnings = manager.process_chapter_result(args.chapter, validated.model_dump(by_alias=True))
-        manager.save_state()
+        save_result = manager.save_state()
+        if not save_result.get("sqlite_sync_ok", True):
+            emit_error(
+                "SQLITE_SYNC_FAILED",
+                "章节状态已写入 state.json,但 SQLite 同步失败",
+                suggestion=f"请运行 webnovel.py projections retry --chapter {args.chapter} 补偿投影",
+                chapter=args.chapter,
+            )
+            raise SystemExit(1)
         emit_success({"chapter": args.chapter, "warnings": warnings}, message="chapter_processed", chapter=args.chapter)
 
     elif args.command == "get-chapter-status":

+ 64 - 0
webnovel-writer/scripts/data_modules/tests/test_archive_manager.py

@@ -1,6 +1,7 @@
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 
+import json
 from pathlib import Path
 
 import pytest
@@ -72,3 +73,66 @@ def test_archive_identify_old_reviews_handles_mixed_formats(archive_env):
     assert len(results) == 3
     assert all(row["chapters_since_review"] >= 5 for row in results)
 
+
+def test_save_archive_uses_atomic_write_json(archive_env, monkeypatch):
+    module = _load_archive_module()
+    manager = module.ArchiveManager(project_root=archive_env)
+    calls = []
+
+    def fake_atomic_write_json(path, data, *, use_lock=True, backup=True, indent=2):
+        calls.append((path, data, use_lock, backup, indent))
+        Path(path).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
+
+    monkeypatch.setattr(module, "atomic_write_json", fake_atomic_write_json)
+
+    manager.save_archive(manager.characters_archive, [{"name": "李雪"}])
+
+    assert calls == [(manager.characters_archive, [{"name": "李雪"}], True, True, 2)]
+
+
+def test_restore_character_keeps_archive_when_sqlite_restore_fails(archive_env, monkeypatch):
+    module = _load_archive_module()
+    manager = module.ArchiveManager(project_root=archive_env)
+    archived = [
+        {
+            "id": "li_xue",
+            "name": "李雪",
+            "tier": "支线",
+            "archived_at": "2026-06-10T00:00:00",
+        }
+    ]
+    manager.characters_archive.write_text(json.dumps(archived, ensure_ascii=False), encoding="utf-8")
+    before = manager.characters_archive.read_text(encoding="utf-8")
+
+    def fail_restore(*args, **kwargs):
+        raise RuntimeError("sqlite down")
+
+    monkeypatch.setattr(manager._index_manager, "update_entity_field", fail_restore)
+
+    assert manager.restore_character("李雪") is False
+    assert manager.characters_archive.read_text(encoding="utf-8") == before
+
+
+def test_restore_character_deletes_archive_after_sqlite_restore_succeeds(archive_env, monkeypatch):
+    module = _load_archive_module()
+    manager = module.ArchiveManager(project_root=archive_env)
+    archived = [
+        {
+            "id": "li_xue",
+            "name": "李雪",
+            "tier": "支线",
+            "archived_at": "2026-06-10T00:00:00",
+        }
+    ]
+    manager.characters_archive.write_text(json.dumps(archived, ensure_ascii=False), encoding="utf-8")
+    calls = []
+
+    def restore_status(entity_id, field, value):
+        calls.append((entity_id, field, value))
+
+    monkeypatch.setattr(manager._index_manager, "update_entity_field", restore_status)
+
+    assert manager.restore_character("李雪") is True
+    assert calls == [("li_xue", "status", "active")]
+    assert json.loads(manager.characters_archive.read_text(encoding="utf-8")) == []
+

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

@@ -119,3 +119,27 @@ def test_init_rejects_english_profile_key_before_writing_state(tmp_path, monkeyp
     assert "rules-mystery" in message
     assert "规则怪谈" in message
     assert not (project_root / ".webnovel" / "state.json").exists()
+
+
+def test_init_preserves_corrupt_state_json_before_rebuilding(tmp_path, monkeypatch, capsys):
+    import init_project as init_project_module
+
+    monkeypatch.setattr(init_project_module, "is_git_available", lambda: False)
+    project_root = tmp_path / "book"
+    state_dir = project_root / ".webnovel"
+    state_dir.mkdir(parents=True)
+    corrupt_text = '{"project_info": '
+    (state_dir / "state.json").write_text(corrupt_text, encoding="utf-8")
+
+    init_project_module.init_project(
+        str(project_root),
+        title="测试书",
+        genre="仙侠",
+        protagonist_name="陆鸣",
+        target_chapters=50,
+    )
+
+    corrupt_copies = sorted(state_dir.glob("state.corrupt_*.json"))
+    assert len(corrupt_copies) == 1
+    assert corrupt_copies[0].read_text(encoding="utf-8") == corrupt_text
+    assert "原 state.json 已损坏" in capsys.readouterr().out

+ 48 - 2
webnovel-writer/scripts/data_modules/tests/test_migrate_state_to_sqlite.py

@@ -172,7 +172,7 @@ def test_migrate_state_backup_and_skips(temp_project):
     assert backups
 
 
-def test_migrate_state_error_branches(tmp_path, monkeypatch):
+def test_migrate_state_error_branches(tmp_path, monkeypatch, capsys):
     cfg = DataModulesConfig.from_project_root(tmp_path)
     cfg.ensure_dirs()
     state = {
@@ -210,5 +210,51 @@ def test_migrate_state_error_branches(tmp_path, monkeypatch):
 
     monkeypatch.setattr(migrate_module, "SQLStateManager", BoomSQL)
 
-    stats = migrate_state_to_sqlite(cfg, dry_run=False, backup=False, verbose=False)
+    stats = migrate_state_to_sqlite(cfg, dry_run=False, backup=False, verbose=True)
+    output = capsys.readouterr().out
     assert stats["errors"] >= 4
+    assert "存在迁移错误,已保留原字段" in output
+
+
+def test_migrate_cli_preserves_state_fields_on_partial_failure(tmp_path, monkeypatch, capsys):
+    cfg = DataModulesConfig.from_project_root(tmp_path)
+    cfg.ensure_dirs()
+    state = {
+        "entities_v3": {"角色": {"boom": {"canonical_name": "爆"}}},
+        "alias_index": {},
+        "state_changes": [],
+        "structured_relationships": [],
+        "relationships": {},
+        "world_settings": {},
+        "plot_threads": {},
+        "review_checkpoints": [],
+        "project_info": {},
+    }
+    cfg.state_file.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
+
+    class BoomSQL:
+        def __init__(self, *args, **kwargs):
+            pass
+
+        def upsert_entity(self, *args, **kwargs):
+            raise RuntimeError("boom")
+
+    monkeypatch.setattr(migrate_module, "SQLStateManager", BoomSQL)
+    monkeypatch.setattr(
+        "sys.argv",
+        [
+            "migrate_state_to_sqlite",
+            "--project-root",
+            str(tmp_path),
+            "--no-backup",
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        migrate_module.main()
+
+    assert exc.value.code == 1
+    output = json.loads(capsys.readouterr().out)
+    assert output.get("status") == "error"
+    saved = json.loads(cfg.state_file.read_text(encoding="utf-8"))
+    assert "entities_v3" in saved

+ 43 - 0
webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py

@@ -612,6 +612,49 @@ def test_state_manager_cli_commands(temp_project, monkeypatch, capsys):
     assert out["status"] == "success"
 
 
+def test_process_chapter_cli_fails_when_sqlite_sync_fails(temp_project, monkeypatch, capsys):
+    if not temp_project.state_file.exists():
+        temp_project.state_file.write_text("{}", encoding="utf-8")
+
+    from data_modules import state_manager as sm
+
+    def fail_sync(self, processed_appearances=None):
+        raise RuntimeError("sqlite sync boom")
+
+    payload = json.dumps(
+        {
+            "entities_appeared": [],
+            "entities_new": [],
+            "state_changes": [],
+            "relationships_new": [],
+        }
+    )
+    monkeypatch.setattr(sm.StateManager, "_sync_pending_patches_to_sqlite", fail_sync)
+    monkeypatch.setattr(
+        sys,
+        "argv",
+        [
+            "state_manager",
+            "--project-root",
+            str(temp_project.project_root),
+            "process-chapter",
+            "--chapter",
+            "7",
+            "--data",
+            payload,
+        ],
+    )
+
+    with pytest.raises(SystemExit) as exc:
+        sm.main()
+
+    assert int(exc.value.code or 0) == 1
+    out = json.loads(capsys.readouterr().out)
+    assert out["status"] == "error"
+    assert out["error"]["code"] == "SQLITE_SYNC_FAILED"
+    assert "webnovel.py projections retry --chapter 7" in out["error"]["suggestion"]
+
+
 def test_state_manager_cli_rejects_json_file_outside_resolved_book_root(tmp_path, monkeypatch):
     from data_modules.config import DataModulesConfig
 

+ 5 - 0
webnovel-writer/scripts/init_project.py

@@ -17,6 +17,7 @@ from __future__ import annotations
 
 import argparse
 import json
+import shutil
 import subprocess
 import sys
 from datetime import datetime
@@ -295,6 +296,10 @@ def init_project(
         try:
             state: Dict[str, Any] = json.loads(state_path.read_text(encoding="utf-8"))
         except json.JSONDecodeError:
+            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
+            corrupt_path = state_path.with_name(f"state.corrupt_{timestamp}.json")
+            shutil.copy2(state_path, corrupt_path)
+            print(f"⚠️ 原 state.json 已损坏,已另存为 {corrupt_path} 供手工抢救")
             state = {}
     else:
         state = {}

+ 19 - 3
webnovel-writer/scripts/run_behavior_evals.py

@@ -297,10 +297,16 @@ def _write_report_artifacts(project_root: Path, *, chapter: int = 1, review_skip
     )
 
 
-def _commit_payload(*, chapter: int = 1, status: str = "accepted", projection_status: dict[str, str] | None = None) -> dict[str, Any]:
+def _commit_payload(
+    *,
+    chapter: int = 1,
+    status: str = "accepted",
+    projection_status: dict[str, str] | None = None,
+    review_result: dict[str, Any] | None = None,
+) -> dict[str, Any]:
     return {
         "meta": {"chapter": chapter, "status": status},
-        "review_result": {"blocking_count": 0},
+        "review_result": review_result or {"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": "摘要"},
@@ -327,7 +333,17 @@ def _eval_user_report_probe(root: Path, case: dict[str, Any]) -> dict[str, Any]:
 
         if scenario == "minimal_review_skipped":
             _write_report_artifacts(project_root, chapter=1, review_skipped=True)
-            _write_json(commit_path, _commit_payload())
+            _write_json(
+                commit_path,
+                _commit_payload(
+                    review_result={
+                        "blocking_count": 0,
+                        "review_skipped": True,
+                        "review_mode": "minimal",
+                        "summary": "minimal mode: reviewer skipped",
+                    }
+                ),
+            )
             (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)

+ 112 - 0
webnovel-writer/scripts/tests/test_backup_manager.py

@@ -21,3 +21,115 @@ def test_backup_manager_gitignore_excludes_env(tmp_path, monkeypatch):
     assert ".env" in gitignore
     assert ".env.*" in gitignore
     assert "!.env.example" in gitignore
+
+
+def _run_git(project_root, *args):
+    return subprocess.run(
+        ["git", *args],
+        cwd=project_root,
+        capture_output=True,
+        text=True,
+        encoding="utf-8",
+        check=False,
+    )
+
+
+def _configure_git_identity(project_root):
+    assert _run_git(project_root, "config", "user.name", "Test Author").returncode == 0
+    assert _run_git(project_root, "config", "user.email", "author@example.com").returncode == 0
+
+
+def test_backup_aborts_when_git_commit_fails_without_identity(tmp_path, monkeypatch, capsys):
+    isolated_home = tmp_path / "home"
+    isolated_home.mkdir()
+    project_root = tmp_path / "project"
+    project_root.mkdir()
+
+    monkeypatch.setenv("HOME", str(isolated_home))
+    monkeypatch.setenv("USERPROFILE", str(isolated_home))
+    monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
+
+    assert _run_git(project_root, "init", "-b", "main").returncode == 0
+    assert _run_git(project_root, "config", "--local", "user.useConfigOnly", "true").returncode == 0
+    _run_git(project_root, "config", "--local", "--unset", "user.name")
+    _run_git(project_root, "config", "--local", "--unset", "user.email")
+
+    manuscript_dir = project_root / "正文"
+    manuscript_dir.mkdir()
+    (manuscript_dir / "第0001章-test.md").write_text("正文", encoding="utf-8")
+
+    manager = GitBackupManager(str(project_root))
+
+    assert manager.backup(1, "身份缺失") is False
+
+    output = capsys.readouterr().out
+    assert "备份失败" in output
+    assert _run_git(project_root, "rev-parse", "--verify", "ch0001").returncode != 0
+
+
+def test_rollback_restores_files_on_current_branch_with_new_commit(tmp_path):
+    project_root = tmp_path / "project"
+    project_root.mkdir()
+    assert _run_git(project_root, "init", "-b", "main").returncode == 0
+    _configure_git_identity(project_root)
+
+    manuscript_dir = project_root / "正文"
+    manuscript_dir.mkdir()
+    chapter_file = manuscript_dir / "第0001章-test.md"
+
+    chapter_file.write_text("第一版", encoding="utf-8")
+    assert _run_git(project_root, "add", ".").returncode == 0
+    assert _run_git(project_root, "commit", "-m", "Chapter 1").returncode == 0
+    assert _run_git(project_root, "tag", "ch0001").returncode == 0
+
+    chapter_file.write_text("第二版", encoding="utf-8")
+    assert _run_git(project_root, "add", ".").returncode == 0
+    assert _run_git(project_root, "commit", "-m", "Chapter 2").returncode == 0
+    assert _run_git(project_root, "tag", "ch0002").returncode == 0
+    before_count = int(_run_git(project_root, "rev-list", "--count", "HEAD").stdout.strip())
+
+    manager = GitBackupManager(str(project_root))
+
+    assert manager.rollback(1) is True
+
+    assert _run_git(project_root, "symbolic-ref", "--short", "HEAD").stdout.strip() == "main"
+    assert chapter_file.read_text(encoding="utf-8") == "第一版"
+    after_count = int(_run_git(project_root, "rev-list", "--count", "HEAD").stdout.strip())
+    assert after_count == before_count + 1
+    assert "rollback: 恢复到 ch0001 备份点" in _run_git(project_root, "log", "-1", "--format=%s").stdout
+
+
+def test_local_backup_copies_manuscript_when_git_unavailable(tmp_path, monkeypatch):
+    monkeypatch.setattr(backup_manager, "is_git_available", lambda: False)
+
+    webnovel_dir = tmp_path / ".webnovel"
+    manuscript_dir = tmp_path / "正文"
+    outline_dir = tmp_path / "大纲"
+    settings_dir = tmp_path / "设定集"
+    webnovel_dir.mkdir()
+    manuscript_dir.mkdir()
+    outline_dir.mkdir()
+    settings_dir.mkdir()
+    (webnovel_dir / "state.json").write_text('{"current_chapter": 1}', encoding="utf-8")
+    (manuscript_dir / "第0001章-x.md").write_text("正文内容", encoding="utf-8")
+    (outline_dir / "第0001章.md").write_text("大纲内容", encoding="utf-8")
+    (settings_dir / "人物.md").write_text("设定内容", encoding="utf-8")
+
+    manager = GitBackupManager(str(tmp_path))
+
+    assert manager.backup(1) is True
+
+    snapshots = sorted((webnovel_dir / "backups").glob("snapshot_ch0001_*"))
+    assert len(snapshots) == 1
+    snapshot = snapshots[0]
+    assert (snapshot / "正文" / "第0001章-x.md").read_text(encoding="utf-8") == "正文内容"
+    assert (snapshot / "大纲" / "第0001章.md").read_text(encoding="utf-8") == "大纲内容"
+    assert (snapshot / "设定集" / "人物.md").read_text(encoding="utf-8") == "设定内容"
+    assert (snapshot / ".webnovel" / "state.json").read_text(encoding="utf-8") == '{"current_chapter": 1}'
+
+    for chapter in range(2, 13):
+        assert manager.backup(chapter) is True
+
+    snapshots = sorted((webnovel_dir / "backups").glob("snapshot_ch*"))
+    assert len(snapshots) == 10
+    assert snapshot not in snapshots