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

feat(v7): M4 review P0——主循环打通(定稿刷新缓存/建书git工程化/原子写盘/重写去重)

按 M1-M4 全量 review 的 P0 backlog 修复主循环四缺口,主流程现在能端到端跑通:

- P0-1 finalize 提交后同步 cache.rebuildFromSource,避免 next 读旧章号重抄本章。
  端到端测试 test/integration/main-loop.test.js 锁定(定稿第1章→next 起草第2章)。
- P0-2 persistCreateBook 建书时 git init + .gitignore(.cache/工作区/) + core.quotepath false
  (spec quality §3.3 钉死建书流程负责);git.js 加 init()/setQuotepathFalse()。
- P0-3 新增 src/storage/atomic.js(writeAtomicBatch 先 .tmp 再 rename),persist 全家 +
  persistReviewReport 接入原子落盘;rebuildCache 包 BEGIN/COMMIT/ROLLBACK 事务。
- P0-3a ChapterWriter 写盘前 removeOldChapterFiles 清同章旧文件 + sanitizeFileName
  净化 Windows 非法字符(顺带修 P2-1),避免 scanChapters 撞 PRIMARY KEY。

附带修(事务化后暴露的过度严格):scanEntities 名册缺失/解析失败从硬错降为软跳过
(warning),否则事务回滚会把 chapters/threads/secrets 一起清了。

测试:252 绿(+6 新);drift check 通过;版本冒烟通过。
发现潜伏 bug(非报告项,留 follow-up):parseMarkdownTable 拒单字符分隔符 |--|--|--|--|
(GFM 合法)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lingfengQAQ преди 10 часа
родител
ревизия
97a7ca2e38

+ 33 - 1
.trellis/tasks/06-27-m4-ai-roles/implement.md

@@ -90,7 +90,39 @@ P5 AC 复核 + CI 双平台;真模型 smoke 推迟文档
 
 ## 后续(M4 完成后,单独任务)
 
-- **M1-M4 全量 review**:通审四个里程碑代码 + 全部迁移产物("不带 v6 问题进 v7"复查);用户明确要求。
+**M1-M4 全量 review**:通审四个里程碑代码 + 全部迁移产物("不带 v6 问题进 v7"复查);用户明确要求。  
+下面这份是从两篇 review 合并出来的修复 backlog,按“先救主流程、再补稳健性、最后回填 spec”排。
+merged 收敛版之外的 deep 报告独有项(作者手改丢失、回滚范围、重建冲突、版本对齐)补在对应段末,标 `(deep)`,避免被合并版吞掉。
+
+### P0 主流程先打通
+
+- [x] P0-1 `src/finalize/index.js` + `src/state-machine/index.js`:定稿后同步刷新缓存,避免 `next` 继续读旧章号。✅ finalize 提交后调 `cache.rebuildFromSource`;端到端测试 `test/integration/main-loop.test.js` 锁定(定稿第1章→next 起草第2章)。
+- [x] P0-2 `src/state-machine/persist.js`:建书时补 git init、`.gitignore`、`core.quotepath false`。✅ `persistCreateBook` 调 `git.init()`+`setQuotepathFalse()`+`buildGitignore`;单测验证三件套。
+- [x] P0-3 `src/state-machine/persist.js` + `src/review/index.js`:多文件写入改成原子落盘,中途失败不留半成品。✅ 新增 `src/storage/atomic.js`(`writeAtomicBatch` 先 .tmp 再 rename);persist 全家 + `persistReviewReport` 接入;`rebuildCache` 包 BEGIN/COMMIT/ROLLBACK 事务。
+- [x] P0-3a (deep) `src/storage/adapters/ChapterWriter.js` + `src/cache/rebuilder.js`:重写同章改标题时旧文件残留 → `scanChapters` 两条同 `chapter_num` → 重建 `PRIMARY KEY` 冲突。✅ `writeChapter` 写盘前 `removeOldChapterFiles` 清同章旧文件 + `sanitizeFileName` 净化 Windows 非法字符(顺带修 P2-1)。
+
+> 附带修:`scanEntities` 名册缺失/解析失败从硬错降为软跳过(warning),避免事务回滚把 chapters/threads/secrets 一起清了——这是事务化后暴露的过度严格。`parseMarkdownTable` 拒单字符分隔符 `|--|--|--|--|`(GFM 合法)是潜伏 parser bug,留作 follow-up(非报告项)。
+
+### P1 两审 / 会话 / 校验
+
+- [ ] P1-1 `src/review/index.js` + `src/dto/character-context.js`:补全 ReviewInput,把相关条目、新专名、别名命中的角色都带进去。
+- [ ] P1-2 `src/review/schema.js`:坏输入先判类型,`blocking` 只收明确布尔,别靠真值强转。
+- [ ] P1-3 `src/review/index.js`:原始审稿结果和归一化结果分开保存,方便回溯模型原话。
+- [ ] P1-4 `src/session/index.js`:`books.jsonl` 部分损坏也触发自愈,必要时回写修复结果。
+- [ ] P1-5 `src/host-shells/validator.js`:扩展绝对路径检测,把常见 Windows / Linux / UNC 形式都算进去。
+- [ ] P1-6 (deep) `src/state-machine/flows/goto-chapter.js`:`--confirm` 走 `reset --hard` 前先 `git stash` 或拒绝脏树;现有 rescue ref 只存 HEAD 指针不含工作树,作者未登记手改会被静默抹掉且无法找回。该 flow 也不跑 `checkGitHealth`。
+- [ ] P1-7 (deep) `src/finalize/index.js` + `src/finalize/git.js`:回滚范围从 `定稿/`+`大纲/` 整棵子树收窄到本次 `written` 文件集合,避免误伤同子树其他章的手改;`git.clean` 包 try/catch(Windows 文件锁抛错会逃出 catch 破坏 `{ok,error}` 契约,`restore` 已有 try)。
+
+### P2 spec 回填
+
+- [ ] P2-1 `.trellis/spec/backend/database-guidelines.md`:补 `entity_aliases` 和表定义边界。
+- [ ] P2-2 `.trellis/spec/backend/error-handling.md`:补多文件原子性边界,以及未知字段保留和嵌套字段处理边界。
+- [ ] P2-3 `.trellis/spec/backend/quality-guidelines.md`:钉死 `core.quotepath` 责任方、AI 调用预算、退出码约定。
+- [ ] P2-4 (deep) 版本号对齐:README 版本徽章 6.2.0(v6/master)与 `v7/package.json` `0.0.0` 不一致;README 版本表是 CI 硬约束(Plugin Version Check),M5 发版前必须对齐,否则 CI 红。
+
+### P3 保留项
+
+- [ ] P3-1 `review-m1-m4.md` / `review-m1-m4-deep.md`:宿主 CLI 缝保留为后续接线项,不作为当前 blocker。
 
 ## 回滚点
 

+ 96 - 0
.trellis/tasks/06-27-m4-ai-roles/review-m1-m4-deep.md

@@ -0,0 +1,96 @@
+# M1–M4 深入 Review 报告(2026-06-29)
+
+> 方法:逐文件精读 73 源文件(非上一份的 grep + 架构断言),交叉 spec 后端规范,跑全量 246 测试复现(绿)。
+> 上一份 review(06-27)的结论(v6 清洁 / 架构自洽 / 246 绿)成立,但**漏掉了核心写章循环里的两个 P0**——它们正好落在 F1(后半段无 CLI 缝)与测试盲区里。
+
+## 结论速览
+
+代码风格、v6 清洁、DTO 隔离、确定性生成(drift)这些都没问题。**但「备料→机检→两审→定稿→next」这条主循环在真实环境下跑不起来**:定稿后缓存不重建且无 rebuild CLI,状态机会重抄最新章;建书流程不 git-init 书仓库。两者都被测试脚手架(预 init git、预落章、手动 rebuild)掩盖。spec 层有 1 处硬矛盾、多处「待增量补充」未闭环。
+
+---
+
+## P0(阻断核心循环)
+
+### P0-1 定稿后缓存不重建 + 无 rebuild CLI → `next` 无限重抄最新章
+- `finalizeChapter`(`src/finalize/index.js:21`)写章、commit、清工作区,**全程不碰 cache**(ctx 收了 `cache?` 却从不引用)。定稿后磁盘多了 `定稿/正文/0003-...`,但 `.cache/index.db` 里 `MAX(chapter_num)` 还是上一章。
+- `bin/webnovel-writer.js:85` 每条命令只调 `cache.ensureReady`,而 `ensureReady`(`src/cache/index.js:21`)**仅在 db 缺失/损坏/空表时重建**——db 存在且有非空表就直接复用,**无任何陈旧检测**。
+- 全树 grep:生产路径里**没有任何地方在定稿后或磁盘变更后调 `rebuildFromSource`**(只有测试调)。
+- `src/commands/` 下**没有 rebuild/refresh-cache 命令**——宿主连「刷新缓存」的 CLI 都没有,唯一刷新手段是作者手动删 `.cache/`。
+- 后果:定稿第 3 章 → 跑 `webnovel-writer next` → 缓存仍说 maxChapter=2 → 序6「起草第 3 章细纲」。第 3 章已在定稿里。**主循环卡死,每章都要作者手动删缓存。**
+- **为何上一份漏了**:`test/finalize/finalize.test.js:96` 在定稿后**手动** `ctx.cache.rebuildFromSource(...)`——测试作者知道要重建,所以断言绿;`test/state-machine/router.test.js` 在 `ensureReady` 之前就把章文件落盘,从不测「定稿后 next」。F1(无定稿 CLI)使整条 finalize→next 从没被端到端测过。
+
+### P0-2 建书流程不 git-init 书仓库 / 不设 core.quotepath / 不写 .gitignore
+- `persistCreateBook`(`src/state-machine/persist.js:29`)只写 `book.yaml` + `总纲.md` + `第01卷.md`,**不 `git init`、不 `git config core.quotepath false`、不写 `.gitignore`**。
+- 后果链:
+  - 新书目录不是 git 仓库 → `finalizeChapter` 的 `git.add/commit` 直接失败 → 每次定稿都回滚报「定稿中断」。
+  - `.cache/index.db` 被当普通文件跟踪/提交,违反 spec database §2.1/2.2(缓存必须 gitignored)。
+  - `工作区/` 不在 `v7/.gitignore`(只 ignore `.cache/ node_modules/ dist/`)→ 草稿/审稿被跟踪,定稿清工作区后留下「已删除未暂存」脏状态。
+- spec `quality-guidelines §3.3` 明确「书仓库初始化必须设 `git config core.quotepath false`」是 CI 硬约束——**目前无任何代码落地**,归属也不清(建书流程?M5 安装器?)。
+- **为何漏了**:`router.test.js:17-29` 的 `makeGitBook` 手动 `git init` + `git config` + 写 `.gitignore('.cache/\n工作区/\n')`,把这三件事全替生产代码做了。测试绿,生产裸。
+
+> P0-1/P0-2 都不是「与 smoke 一起推迟」能解释的——它们是脚本面(M1-M4 范围内)的真实缺口,只是因为 smoke 推迟、没有端到端测试而没暴露。建议进 M5 前**先补上**,否则 M5 安装器接一个跑不通的循环。
+
+---
+
+## P1(数据完整性 / 安全)
+
+### P1-1 缓存重建无事务 → 中途失败留半库
+`rebuildCache`(`src/cache/rebuilder.js:12`)DELETE 6 表后逐表 INSERT,**无 `BEGIN/COMMIT`**。`scanEntities` 在别名冲突时 `return {ok:false}`(`:241`),此时 chapters/threads/secrets 已写入、entities/aliases/characters 空——**返回失败但库已半填**,`CacheManager` 不在失败时重建。与上一份「数据完整性 SOUND」断言冲突。注:注释说「DELETE 五表」实际删 6 表。
+
+### P1-2 `goto-chapter --confirm` 不 stash 未提交改动 → `reset --hard` 丢手改
+`src/state-machine/flows/goto-chapter.js:33` 先 `createBackupRef`(只存 HEAD 指针)再 `resetHard(hash)`。`git reset --hard` 丢弃**所有已跟踪文件的未提交改动**,rescue ref 不含工作树。若作者有序 2 式未登记手改(定稿/大纲已跟踪且改了),改动被静默抹掉、无法从 rescue ref 找回。该 flow 也不跑 `checkGitHealth`。应:reset 前先 `git stash` 或拒绝脏树。
+
+### P1-3 finalize 回滚是 tree-scoped 非 write-scoped + `clean` 未包 try
+`src/finalize/index.js:112-113` 回滚用 `git.restore(['定稿/','大纲/'])` + `git.clean(['定稿/','大纲/'])`:
+- 范围是**整棵 定稿/大纲 子树**,不是本次 `written` 集合。若本次只写第 5 章、但第 4 章有未提交手改,回滚会把第 4 章手改一起抹掉。注释自称「仅 定稿/大纲」是树级不是文件级。
+- `git.clean`(`src/finalize/git.js:33`)**没有 try/catch**(`restore` 有)。Windows 文件锁使 clean 抛错时,错误逃出 `finalizeChapter` 的 catch,破坏 `{ok,error}` 契约,调用方拿到 throw 而非结构化失败。
+
+---
+
+## P2(健壮性 / 正确性,不阻断)
+
+- **P2-1 章标题未做文件名净化**:`ChapterWriter.writeChapter`(`src/storage/adapters/ChapterWriter.js:24`)直接 `${NNNN}-${标题}.md`。标题含 `? * " < > |` 或半角冒号时 Windows 写盘失败 → finalize 回滚。标题是 AI DTO,无 sanitize。
+- **P2-2 重写同章不同标题留旧文件**:`writeChapter` 用 writeFile 覆盖同文件名,标题变了文件名就变,旧文件残留 → `scanChapters` 两条同 `chapter_num` → `INSERT ... PRIMARY KEY` 冲突 → 重建失败。
+- **P2-3 `updateFrontMatter` throw / `installer` 占位**:已知 F3,无人依赖,诚实占位,留未来。
+- **P2-4 `yaml-dialect.needsQuoting` 不全**(`src/storage/serializers/yaml-dialect.js:79`):只查数字/布尔/null/冒号/`#`/`-`/换行。漏 `[` `{` `&` `*` `!` `|` `>` `%` `@` 等首字符 → 值如 `[弱钩]`、`{x}` 会被 YAML 误判为数组/映射。当前数据形状碰不到,潜伏坑。
+- **P2-5 线程更新遇嵌套未知字段必崩**:`ThreadLedgerWriter.updateThread`(`src/storage/adapters/ThreadLedgerWriter.js:29`)调 `serializeFrontMatter(merged, parsed.body)` 不传 `originalYAML`。merged 含全部原字段,平铺字段能保留;但若作者加了**嵌套映射**自定义字段,`serializeYAML` 抛「防呆方言禁止嵌套」→ updateThread 返回 ok:false → finalize 回滚。与 spec §4.5「未知字段必须保留原样写回」**直接冲突**(见 S2)。
+- **P2-6 机检复读仅报首条 + 阈值偏低**:`checkRepetition`(`src/mechanical-check/index.js:100`)`break` 在首条命中,漏报其余;L=6/阈值 3 对常见六字短语易误报。`checkNewProperNouns` 正则 `([一-龥]{2,3})(…|道|说|…)` 对「他便笑道」类产生大量非阻断候选噪声(不拦截,仅噪)。
+- **P2-7 两审「相关角色」按正名匹配漏别名**:`assembleReviewInput`(`src/review/index.js:43`)用 `草稿全文.includes(name)`(name=文件名=正名)。草稿只用别名「大师兄」、文件名「林晚」时漏纳入,审查上下文缺失。
+- **P2-8 `session.assembleSessionContext` 自愈不回写**(`src/session/index.js:57`):books.jsonl 损坏时只扫描重建到内存返回,**不写回 books.jsonl**,下个会话再扫一遍。函数名/注释「自愈」名不副实(spec 说本层只读、M5 写侧——但「自愈」措辞误导,建议改名或 spec 注明)。
+- **P2-9 `findChapterCommit` 依赖 git 默认 BRE**:`git.js:93` `--grep=ch(${n}):` 能匹配字面 `ch(3):` 仅因 git 默认 BRE 里 `()` 是字面。一旦 `grep.extendedRegex` 配置开启即失效。脆弱但当前正确。
+- **P2-10 宿主壳生成器字符串拼接**:`roleToToml`/`roleToMarkdown`(`src/host-shells/generate.js:42-47`)直接拼 `description = "${role.description}"`。description 含 `"` 或换行即破 TOML/YAML frontmatter。`validator` 只查源 roles 的 ABS_PATH,**不校验生成物格式**,drift check 也只比字节相等不比合法。
+- **P2-11 `validator.ABS_PATH` 漏路径**(`src/host-shells/validator.js:9`):只匹配 `[A-Za-z]:\\` 和 `/(Users|home)/`。`/root/`、`/mnt/`、裸 `/etc/x` 都漏。便携性校验有洞。
+
+---
+
+## Spec 层问题
+
+- **S1 表数不符**:`database-guidelines §2.4` 列五表(chapters/threads/secrets/entities/fingerprints),实现六表(多了 `entity_aliases`)。spec 漏列 `entity_aliases`,文档与代码不一致。
+- **S2 硬矛盾:保留未知字段 vs 禁止嵌套**:§4.5「未知字段必须保留原样写回」与 §4.1「禁止嵌套映射」+ 防呆序列化器拒嵌套(P2-5)冲突。作者加嵌套自定义字段时无法两全。spec 需明确:未知字段仅限平铺标量/列表,嵌套字段如何处置(拒绝?降级为正文?)。
+- **S3 core.quotepath 落地无归属**:`quality §3.3` 要求书仓库初始化设 `core.quotepath false`,但未指定由哪一步负责(建书流程?安装器?),导致无人实现(P0-2)。spec 应钉死责任方。
+- **S4 AI 预算上限 pending**:`quality §2.2`「AI 调用每章预算上限(实现时定数)」M4 已过仍未定数,`runReviews` 固定 2 次调用无上限约束。应在 M4 收口或显式推迟到 beta。
+- **S5 多文件原子性豁免缺失**:`error-handling §3.1`「所有多文件写入操作必须原子」,但 `persistReviewReport`(`src/review/index.js:104`)写 3 个文件非原子无回滚。spec 未给「工作区多文件写入」豁免边界,要么补豁免、要么补实现。
+- **S6 「待增量补充」大量未闭环**:database §5(表列定义/增量更新策略)、error §5(错误类型与退出码、git 异常清单)、quality §6(lint 选型、测试覆盖率、退出码约定)自基线 1.0(06-12)至今未填。M1-M4 已落地,这些空白该回填,否则后续任务无据。
+- **S7 书仓库工程文件边界含糊**:`directory-structure §3.3`「书仓库内零工程文件(唯一例外 AGENTS.md)」未说明 `.gitignore` 归位,而 `.cache/` 必须被 ignore——存在规范真空,导致 P0-2 里 `.cache` 被跟踪无人挡。
+- **S8 版本号未对齐**:README 版本徽章 6.2.0(v6/master),`v7/package.json` 是 `0.0.0`。发布前须设版本;README 版本表是 CI 硬约束,M5 发版前要对齐。
+
+---
+
+## 测试盲区(解释为何 246 绿却藏 P0/P1)
+
+1. 无 finalize→next 端到端(F1 无定稿 CLI)→ 主循环从未被测。
+2. `router.test` 在 `ensureReady` 前已落章 → 不测「定稿后 next 陈旧」。
+3. `finalize.test` 手动 `rebuildFromSource` → 掩盖「无自动重建」。
+4. `makeGitBook` 手动 `git init`+`config`+`.gitignore` → 掩盖「建书不 init git」。
+5. `goto-chapter.test` 未构造「脏工作树 + confirm」用例 → P1-2 不触发。
+
+建议补一条端到端集成:`init 书 → 备料 → 机检 → runReviews(桩) → finalize → next`,期望 `next` 报序6 且 nextChapter = 已定稿章 +1。这条一加,P0-1 立刻红。
+
+---
+
+## 优先级建议
+
+1. 先修 P0-1(定稿后重建缓存 + 补 rebuild CLI 或在 `next`/`finalize` 入口刷新)+ P0-2(建书流程 git init + .gitignore + core.quotepath)——这是让主循环跑通的前提。
+2. 再补上面那条端到端集成测试,把 P0 锁死。
+3. P1(事务、goto stash、回滚范围+clean try)在 M5 接线前修。
+4. P2 与 S1-S8 可在 M5 期间或单独一个 spec 回填任务里清。

+ 98 - 0
.trellis/tasks/06-27-m4-ai-roles/review-m1-m4-merged.md

@@ -0,0 +1,98 @@
+# M1-M4 合并 Review(最终版)
+
+> 依据:两份既有 review 的交集与差异合并而成。这里保留我自己的判断:把“会让主循环跑不起来”的问题放到最前,`F1` 级别的宿主 CLI 缝降为 follow-up,不抢 blocker 位。
+
+## 结论
+
+整体上,M1-M4 的架构方向是对的:DTO 隔离、确定性编排、宿主壳生成、知识迁移这几条主线都立住了。  
+但如果按“真实作者流程能不能稳定跑通”来审,当前仍有几处会直接卡住主循环,不能算完全收口。
+
+**结论分层:**
+- **Blocker**:主循环与持久态一致性问题,必须先修。
+- **高优先级问题**:review / session / validator 的健壮性缺口,会导致静默漏料或错误接受。
+- **Spec 问题**:规范本身有矛盾或责任方未钉死,需要回填,否则后续实现会继续漂。
+
+## Blocker
+
+### B1. 定稿后缓存不重建,`next` 可能重抄旧章
+
+`finalize` 写入新正文后没有同步刷新 `.cache/index.db`,而 `next` 侧只会在缓存缺失/损坏/空表时重建。  
+这会导致磁盘已经写了新章,但状态机仍按旧 `MAX(chapter_num)` 继续跑,出现“已定稿第 3 章,却又起第 3 章细纲”的回环。
+
+这是主循环级问题,不是 smoke 推迟能覆盖掉的。
+
+### B2. 建书流程没有把 git 仓库初始化责任落到生产代码
+
+当前 `persistCreateBook` 只写 `book.yaml` / `总纲.md` / `第01卷.md`,没有把 `git init`、`core.quotepath false`、`.gitignore` 这几件事真正落地。  
+结果是新书仓库未必可直接定稿,缓存也可能被错误跟踪,后续写章和提交链路会断。
+
+### B3. 多文件写入没有原子边界
+
+`persistCreateBook`、`persistVolumeReview`、`persistRepair`、`persistReviewReport` 都是顺序写多个文件。中途失败会留下半套产物。  
+这和“定稿必须原子”“多文件写入要么全成要么原样保留”的规范冲突,属于实打实的数据完整性风险。
+
+## 高优先级问题
+
+### H1. ReviewInput 组装不全,且角色命中只认正名
+
+两审输入里,设计要求的 `相关条目`、`名册待确认新专名` 还没有真正进入 DTO。  
+另外,相关角色靠 `草稿全文.includes(name)` 按文件名正名命中,别名不会被纳入。这样会让审查上下文缺料,尤其是作者正文里常用别名时。
+
+### H2. Review schema 对坏输入不够安全
+
+`validateReviewReport` 默认假设 `issues` 里的每个元素都是对象。只要混进 `null`、字符串或别的坏值,就可能直接抛异常。  
+同时 `blocking` 现在是 `!!issue.blocking`,字符串 `"false"` 这类值会被当成真,语义上太松。
+
+### H3. 审稿报告保存的是归一化结果,不是原始输出
+
+设计文档写的是保存 raw output,但当前 `评审报告/事实审查.json`、`编辑审.json` 实际写入的是校验器复算后的对象。  
+这会让后续排查模型漂移、对比原始输出变难。
+
+### H4. `books.jsonl` 部分损坏时没有触发真正的自愈
+
+现在只要 `books.jsonl` 还能读出部分有效行,就不会进入扫描重建。坏行会被吞掉,但不会回写修复。  
+这会让书单长期处在“半坏不坏”的状态。
+
+### H5. 绝对路径检测范围太窄
+
+host shell validator 里的绝对路径正则只覆盖少数 Windows/Linux 用户目录形态,像 `/tmp/...`、`/opt/...`、UNC 路径、`C:/...` 这类都可能漏掉。  
+这会削弱“生成物不带本机绝对路径”的保证。
+
+## Spec 问题
+
+### S1. 数据表定义与实现不一致
+
+规范里列的是五张表,但实现里实际上还有 `entity_aliases`。  
+这不是代码错,是 spec 漏列了,后续如果不回填,所有审查都会在这里卡一次。
+
+### S2. “未知字段保留” 与 “禁止嵌套映射” 的边界没钉死
+
+规范一边说未知字段要保留原样写回,一边又禁止嵌套映射。  
+这两个约束现在没有明确边界,作者一旦加自定义嵌套字段,脚本会和规范互相打架。
+
+### S3. `core.quotepath false` 的责任方没明确
+
+规范要求它必须设置,但没有说明是建书、安装器还是别的入口负责。  
+没有责任方,就很容易像现在这样没人真正实现。
+
+### S4. 多文件原子性边界未豁免或未收口
+
+错误处理规范要求多文件写入原子,但 review / persist 这条链路天然就是多文件。  
+规范要么补豁免边界,要么补统一的原子写入模式,否则实现永远会显得“不合规”。
+
+### S5. 一些“待增量补充”长期未闭环
+
+数据库列定义、错误退出码、AI 调用预算、日志/CLI 约定等都还停在“待增量补充”。  
+M1-M4 已经推进到能跑主流程的阶段,这些空白该回填,不然后续任务的依据会一直悬着。
+
+## 需要保留的判断
+
+第一份 review 提到的“宿主 CLI 缝”和真模型 smoke 推迟,我保留,但它更像后续接线问题,不是当前最核心的阻断项。  
+第二份 review 抓到的主循环缺口更重,所以最终版应以前者为 follow-up、以后者为 blocker。
+
+## 最终排序
+
+1. 先修 B1/B2/B3,保证主循环和持久态不再断。
+2. 再补 H1-H5,把 review / session / validator 的漏口堵上。
+3. 同步回填 S1-S5,免得后续实现继续和 spec 打架。
+4. F1 级别的宿主 CLI 缝继续保留为 follow-up,不抢 blocker 位。

+ 26 - 5
v7/src/cache/rebuilder.js

@@ -4,7 +4,7 @@ import { parseFrontMatter } from '../storage/parsers/front-matter.js'
 import { parseMarkdownTable } from '../storage/parsers/markdown-table.js'
 
 /**
- * 全量重建缓存(DELETE 五表 → 扫描源文件 → INSERT)。
+ * 全量重建缓存(BEGIN → DELETE 六表 → 扫描源文件 INSERT → COMMIT,中途失败 ROLLBACK 不留半库)。
  * @param {string} repoPath - 书仓库根目录
  * @param {DatabaseSync} db - node:sqlite 数据库实例
  * @returns {Promise<{ok: boolean, warnings: string[], errors: string[]}>}
@@ -14,7 +14,10 @@ export async function rebuildCache(repoPath, db) {
   const errors = []
 
   try {
-    // 1. 清空五表
+    // P0-3/P1-1:整次重建包在一个事务里,别名冲突等中途失败 → ROLLBACK,不留半填库
+    db.exec('BEGIN')
+
+    // 1. 清空六表(chapters/threads/secrets/entities/entity_aliases/fingerprints)
     db.exec('DELETE FROM chapters')
     db.exec('DELETE FROM threads')
     db.exec('DELETE FROM secrets')
@@ -33,7 +36,9 @@ export async function rebuildCache(repoPath, db) {
 
     // 5. 解析名册 → 填充 entities + entity_aliases 表(验证别名唯一性)
     const aliasCheck = await scanEntities(repoPath, db)
+    if (aliasCheck.warnings) warnings.push(...aliasCheck.warnings)
     if (!aliasCheck.ok) {
+      db.exec('ROLLBACK')
       errors.push(...aliasCheck.errors)
       return { ok: false, warnings, errors }
     }
@@ -43,8 +48,14 @@ export async function rebuildCache(repoPath, db) {
 
     // 7. fingerprints 表留空(特征提取随 M3+ 体检补)
 
+    db.exec('COMMIT')
     return { ok: true, warnings, errors }
   } catch (err) {
+    try {
+      db.exec('ROLLBACK')
+    } catch {
+      // 未处于事务中,忽略
+    }
     errors.push(`重建失败:${err.message}`)
     return { ok: false, warnings, errors }
   }
@@ -205,17 +216,27 @@ async function scanSecrets(repoPath, db) {
 
 /**
  * 解析名册,填充 entities + entity_aliases 表(验证别名唯一性)。
+ * 名册非必需:缺失则跳过(角色卡可独立入 entities);解析失败/别名冲突才算硬错。
  */
 async function scanEntities(repoPath, db) {
   const rosterPath = path.join(repoPath, '定稿', '设定', '名册.md')
   const aliasMap = new Map() // alias → entity_id
 
+  let content
+  try {
+    content = await fs.readFile(rosterPath, 'utf8')
+  } catch {
+    // 无名册:跳过(角色卡 scanCharacters 仍会入档),不算失败
+    return { ok: true, errors: [] }
+  }
+
   try {
-    const content = await fs.readFile(rosterPath, 'utf8')
     const table = parseMarkdownTable(content)
 
     if (!table.ok) {
-      return { ok: false, errors: ['名册解析失败'] }
+      // 名册格式解析不动 → 软跳过(不阻断重建;chapters/threads/secrets 不应因此回滚)。
+      // 别名冲突才是硬错(见下),走 ok:false 触发 ROLLBACK。
+      return { ok: true, errors: [], warnings: [`名册解析失败,已跳过实体/别名入库:${table.error}`] }
     }
 
     const entityStmt = db.prepare(`
@@ -250,7 +271,7 @@ async function scanEntities(repoPath, db) {
 
     return { ok: true, errors: [] }
   } catch (err) {
-    return { ok: false, errors: ['名册文件不存在或解析失败'] }
+    return { ok: false, errors: [`名册扫描失败:${err.message}`] }
   }
 }
 

+ 8 - 0
v7/src/finalize/git.js

@@ -21,6 +21,14 @@ export function createGit(repoPath) {
       const { stdout } = await run(['rev-parse', 'HEAD'])
       return stdout.trim()
     },
+    /** 仓库初始化(幂等:已存在则 reinit,无害) */
+    async init() {
+      await run(['init', '-q'])
+    },
+    /** 中文路径不转义(spec quality §3.3:书仓库初始化必设) */
+    async setQuotepathFalse() {
+      await run(['config', 'core.quotepath', 'false'])
+    },
     /** 撤销 paths 下已跟踪文件的未提交修改(不碰其他路径如 工作区/) */
     async restore(paths) {
       try {

+ 10 - 0
v7/src/finalize/index.js

@@ -100,6 +100,16 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
     await git.add(relFiles)
     const commitHash = await git.commit(buildCommitMessage(chapterNum, frontMatter.标题, commitLines))
 
+    // P0-1:定稿后同步刷新缓存,避免 next 读旧章号重抄本章。
+    // 重建失败不阻断定稿(已 commit 入档);next 入口 ensureReady 会在 db 损坏时兜底重建。
+    if (ctx.cache) {
+      try {
+        await ctx.cache.rebuildFromSource(repoPath)
+      } catch {
+        // 缓存重建尽力而为
+      }
+    }
+
     // 4. 清工作区(必须在 commit 成功之后)
     for (const wf of workspaceFiles) {
       await fs.rm(path.join(repoPath, '工作区', wf), { force: true })

+ 9 - 8
v7/src/review/index.js

@@ -5,6 +5,7 @@ import { extractSection } from '../util/markdown.js'
 import { assembleCharacterContext } from '../dto/character-context.js'
 import { TimelineReader } from '../storage/adapters/TimelineReader.js'
 import { SecretReader } from '../storage/adapters/SecretReader.js'
+import { writeAtomicBatch } from '../storage/atomic.js'
 import { validateReviewReport } from './schema.js'
 
 const 兼容声明 = '本次使用兼容模式(单上下文顺序审稿),审稿隔离度低于完整两审模式。'
@@ -103,12 +104,6 @@ export function mergeReviews({ factCheck, editorial }, { mode, chapterNum }) {
  */
 export async function persistReviewReport(ctx, { chapterNum, merged, draft, 待确认新专名 = [], 章摘要 = '' }) {
   const { repoPath } = ctx
-  const 工作区 = path.join(repoPath, '工作区')
-  const 报告dir = path.join(工作区, '评审报告')
-  await fs.mkdir(报告dir, { recursive: true })
-
-  await fs.writeFile(path.join(报告dir, '事实审查.json'), JSON.stringify(merged.事实审查 ?? {}, null, 2), 'utf8')
-  await fs.writeFile(path.join(报告dir, '编辑审.json'), JSON.stringify(merged.编辑审 ?? {}, null, 2), 'utf8')
 
   const issueLines = merged.issues.length
     ? merged.issues
@@ -136,8 +131,14 @@ export async function persistReviewReport(ctx, { chapterNum, merged, draft, 待
     '',
   ].join('\n')
 
-  const 审稿路径 = path.join(工作区, '审稿.md')
-  await fs.writeFile(审稿路径, md, 'utf8')
+  // P0-3:多文件原子落盘(事实审查/编辑审/审稿单要么全成要么原样)
+  await writeAtomicBatch(repoPath, [
+    { path: path.join('工作区', '评审报告', '事实审查.json'), content: JSON.stringify(merged.事实审查 ?? {}, null, 2) },
+    { path: path.join('工作区', '评审报告', '编辑审.json'), content: JSON.stringify(merged.编辑审 ?? {}, null, 2) },
+    { path: path.join('工作区', '审稿.md'), content: md },
+  ])
+
+  const 审稿路径 = path.join(repoPath, '工作区', '审稿.md')
   return { ok: true, 审稿路径, error: '' }
 }
 

+ 48 - 23
v7/src/state-machine/persist.js

@@ -2,36 +2,56 @@ import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { serializeYAML } from '../storage/serializers/yaml-dialect.js'
 import { parseFrontMatter } from '../storage/parsers/front-matter.js'
+import { writeAtomicBatch } from '../storage/atomic.js'
+import { createGit } from '../finalize/git.js'
 
 /**
  * AI 态产物回流落盘(M3 落盘,AI 不碰文件)。AI 提交结构化 DTO,本层映射到路径写出。
- * 与 dto.js(读侧组装)对称。
+ * 与 dto.js(读侧组装)对称。多文件写入走 writeAtomicBatch(要么全成要么原样,spec error-handling §3.1)。
  */
 
-async function writeFile(repoPath, rel, content) {
-  const full = path.join(repoPath, rel)
-  await fs.mkdir(path.dirname(full), { recursive: true })
-  await fs.writeFile(full, content, 'utf8')
-  return rel
+/** 读现有 .gitignore,补齐缺失的必需条目,返回合并后内容。 */
+async function buildGitignore(repoPath, required) {
+  const gi = path.join(repoPath, '.gitignore')
+  let existing = ''
+  try {
+    existing = await fs.readFile(gi, 'utf8')
+  } catch {
+    // 无 .gitignore
+  }
+  const lines = existing.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
+  for (const e of required) {
+    if (!lines.includes(e)) lines.push(e)
+  }
+  return lines.join('\n') + '\n'
 }
 
 /** 序6 起草细纲 → 工作区/细纲.md */
 export async function persistDraftOutline(ctx, { 细纲 }) {
   try {
-    const rel = await writeFile(ctx.repoPath, path.join('工作区', '细纲.md'), 细纲)
-    return { ok: true, written: [rel], error: '' }
+    const written = await writeAtomicBatch(ctx.repoPath, [
+      { path: path.join('工作区', '细纲.md'), content: 细纲 },
+    ])
+    return { ok: true, written, error: '' }
   } catch (err) {
     return { ok: false, written: [], error: `落盘细纲失败:${err.message}` }
   }
 }
 
-/** 序1 建书 → book.yaml + 大纲/总纲.md + 大纲/第01卷.md */
+/** 序1 建书 → book.yaml + 大纲/总纲.md + 大纲/第01卷.md + .gitignore + git init + core.quotepath */
 export async function persistCreateBook(ctx, { book, 总纲, 卷纲 }) {
   try {
-    const written = []
-    written.push(await writeFile(ctx.repoPath, 'book.yaml', serializeYAML(book)))
-    written.push(await writeFile(ctx.repoPath, path.join('大纲', '总纲.md'), 总纲))
-    written.push(await writeFile(ctx.repoPath, path.join('大纲', '第01卷.md'), 卷纲))
+    const gitignore = await buildGitignore(ctx.repoPath, ['.cache/', '工作区/'])
+    const written = await writeAtomicBatch(ctx.repoPath, [
+      { path: 'book.yaml', content: serializeYAML(book) },
+      { path: path.join('大纲', '总纲.md'), content: 总纲 },
+      { path: path.join('大纲', '第01卷.md'), content: 卷纲 },
+      { path: '.gitignore', content: gitignore },
+    ])
+    // P0-2:书仓库工程化(spec quality §3.3 钉死建书流程负责 git init + core.quotepath)
+    const git = createGit(ctx.repoPath)
+    await git.init()
+    await git.setQuotepathFalse()
     return { ok: true, written, error: '' }
   } catch (err) {
     return { ok: false, written: [], error: `建书落盘失败:${err.message}` }
@@ -41,17 +61,18 @@ export async function persistCreateBook(ctx, { book, 总纲, 卷纲 }) {
 /** 序4 卷复盘 → 定稿/摘要/卷摘要/NN.md + 大纲/第{卷号+1}卷.md(+ 可选伏笔条目) */
 export async function persistVolumeReview(ctx, { 卷号, 卷摘要, 下卷卷纲, 伏笔条目 = [] }) {
   try {
-    const written = []
+    const files = []
     const nn = String(卷号).padStart(2, '0')
-    written.push(await writeFile(ctx.repoPath, path.join('定稿', '摘要', '卷摘要', `${nn}.md`), 卷摘要))
+    files.push({ path: path.join('定稿', '摘要', '卷摘要', `${nn}.md`), content: 卷摘要 })
     if (下卷卷纲) {
       const next = String(卷号 + 1).padStart(2, '0')
-      written.push(await writeFile(ctx.repoPath, path.join('大纲', `第${next}卷.md`), 下卷卷纲))
+      files.push({ path: path.join('大纲', `第${next}卷.md`), content: 下卷卷纲 })
     }
     for (const e of 伏笔条目) {
       const body = `---\n${serializeYAML(e.frontMatter || {})}\n---\n${e.body || ''}`
-      written.push(await writeFile(ctx.repoPath, path.join('大纲', '伏笔', `${e.id}.md`), body))
+      files.push({ path: path.join('大纲', '伏笔', `${e.id}.md`), content: body })
     }
+    const written = await writeAtomicBatch(ctx.repoPath, files)
     return { ok: true, written, error: '' }
   } catch (err) {
     return { ok: false, written: [], error: `卷复盘落盘失败:${err.message}` }
@@ -63,18 +84,22 @@ export async function persistVolumeReview(ctx, { 卷号, 卷摘要, 下卷卷纲
  * 只写在 allowedFiles(M3 检测到的失败清单)内的文件;修复内容必须能解析,否则不写。
  */
 export async function persistRepair(ctx, { repairs }, { allowedFiles = [] } = {}) {
-  const written = []
   for (const r of repairs) {
     if (!allowedFiles.includes(r.file)) {
-      return { ok: false, written, error: `拒绝写入非失败清单文件:${r.file}` }
+      return { ok: false, written: [], error: `拒绝写入非失败清单文件:${r.file}` }
     }
     const parsed = parseFrontMatter(r.content)
     if (!parsed.ok) {
-      return { ok: false, written, error: `修复内容仍解析失败(${r.file}):${parsed.error}` }
+      return { ok: false, written: [], error: `修复内容仍解析失败(${r.file}):${parsed.error}` }
     }
   }
-  for (const r of repairs) {
-    written.push(await writeFile(ctx.repoPath, r.file, r.content))
+  try {
+    const written = await writeAtomicBatch(
+      ctx.repoPath,
+      repairs.map((r) => ({ path: r.file, content: r.content }))
+    )
+    return { ok: true, written, error: '' }
+  } catch (err) {
+    return { ok: false, written: [], error: `修复落盘失败:${err.message}` }
   }
-  return { ok: true, written, error: '' }
 }

+ 27 - 1
v7/src/storage/adapters/ChapterWriter.js

@@ -5,6 +5,30 @@ import { serializeFrontMatter } from '../serializers/front-matter.js'
 /**
  * ChapterWriter:写新章到定稿(M2 定稿流程调用)。
  */
+
+/** 文件名净化:Windows 非法字符 <>:"/\|?* 与控制字符替成 _(标题本体不改,只净化文件名)。 */
+function sanitizeFileName(title) {
+  const s = String(title).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/\s+/g, ' ').trim()
+  return s || '未命名'
+}
+
+/** 删除同章旧文件(标题可能不同),避免 scanChapters 撞 PRIMARY KEY(P0-3a)。 */
+async function removeOldChapterFiles(dir, chapterNum, safeTitle) {
+  const prefix = `${String(chapterNum).padStart(4, '0')}-`
+  const target = `${prefix}${safeTitle}.md`
+  let files = []
+  try {
+    files = await fs.readdir(dir)
+  } catch {
+    return
+  }
+  for (const f of files) {
+    if (!f.startsWith(prefix) || !f.endsWith('.md')) continue
+    if (f === target) continue
+    await fs.rm(path.join(dir, f), { force: true })
+  }
+}
+
 export class ChapterWriter {
   constructor(repoPath, cache = null) {
     this.repoPath = repoPath
@@ -21,9 +45,11 @@ export class ChapterWriter {
   async writeChapter(chapterNum, frontMatter, body) {
     try {
       const title = frontMatter.标题 || '未命名'
-      const fileName = `${String(chapterNum).padStart(4, '0')}-${title}.md`
+      const safeTitle = sanitizeFileName(title)
       const dir = path.join(this.repoPath, '定稿', '正文')
       await fs.mkdir(dir, { recursive: true })
+      await removeOldChapterFiles(dir, chapterNum, safeTitle)
+      const fileName = `${String(chapterNum).padStart(4, '0')}-${safeTitle}.md`
       const filePath = path.join(dir, fileName)
       await fs.writeFile(filePath, serializeFrontMatter(frontMatter, body), 'utf8')
       return { ok: true, filePath, error: '' }

+ 33 - 0
v7/src/storage/atomic.js

@@ -0,0 +1,33 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+
+let counter = 0
+
+/**
+ * 原子批量写:全部先落 .tmp 再 rename,任一失败回滚(删 tmp)。
+ * 同目录 rename 原子(同卷),保证多文件"要么全成要么原样"(spec error-handling §3.1)。
+ * @param {string} repoPath
+ * @param {Array<{path: string, content: string}>} files 相对路径
+ * @returns {Promise<string[]>} 已写的相对路径
+ */
+export async function writeAtomicBatch(repoPath, files) {
+  const plans = []
+  for (const f of files) {
+    const full = path.join(repoPath, f.path)
+    await fs.mkdir(path.dirname(full), { recursive: true })
+    const tmp = `${full}.wnwtmp.${process.pid}.${counter++}`
+    await fs.writeFile(tmp, f.content, 'utf8')
+    plans.push({ tmp, final: full, rel: f.path })
+  }
+  try {
+    for (const p of plans) {
+      await fs.rename(p.tmp, p.final)
+    }
+  } catch (err) {
+    for (const p of plans) {
+      await fs.rm(p.tmp, { force: true })
+    }
+    throw err
+  }
+  return plans.map((p) => p.rel)
+}

+ 25 - 0
v7/test/cache/rebuilder.test.js

@@ -101,3 +101,28 @@ test('别名冲突 → 报 error 并拒绝重建(AC10)', async () => {
     await rm(root, { recursive: true, force: true })
   }
 })
+
+test('别名冲突 → ROLLBACK 不留半库(P1-1:已写 chapters 也回滚)', async () => {
+  const root = await makeRepo({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测试\n',
+    '定稿/正文/0001-开局.md': '---\n章号: 1\n标题: 开局\n卷: 1\n字数: 100\n章定位: 推进\n---\n正文。',
+    '定稿/正文/0002-承接.md': '---\n章号: 2\n标题: 承接\n卷: 1\n字数: 100\n章定位: 推进\n---\n正文。',
+    // 别名冲突:scanEntities 在 chapters/threads/secrets 已 INSERT 后才返回失败
+    '定稿/设定/名册.md':
+      '| 正名 | 别名 | 类型 | 首现章 |\n|------|------|------|--------|\n' +
+      '| 林晚 | 影 | character | 1 |\n| 老者 | 影 | character | 1 |\n',
+  })
+  const cache = new CacheManager(path.join(root, '.cache', 'index.db'))
+  try {
+    const result = await cache.rebuildFromSource(root)
+    assert.equal(result.ok, false, '别名冲突应拒绝重建')
+    // 事务回滚:chapters 不应残留半填数据
+    const ch = await cache.query('SELECT COUNT(*) AS c FROM chapters')
+    assert.equal(ch[0].c, 0, '别名冲突应 ROLLBACK,chapters 不留半填数据')
+    const th = await cache.query('SELECT COUNT(*) AS c FROM threads')
+    assert.equal(th[0].c, 0, 'threads 同样回滚')
+  } finally {
+    await cache.close()
+    await rm(root, { recursive: true, force: true })
+  }
+})

+ 102 - 0
v7/test/integration/main-loop.test.js

@@ -0,0 +1,102 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import os from 'node:os'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { CacheManager } from '../../src/cache/index.js'
+import { persistCreateBook, persistDraftOutline } from '../../src/state-machine/persist.js'
+import { runReviews } from '../../src/review/index.js'
+import { finalizeChapter } from '../../src/finalize/index.js'
+import { determineNextState } from '../../src/state-machine/index.js'
+
+const exec = promisify(execFile)
+
+// 桩两审:零问题通过,用于驱动主循环不引入真模型
+const stubReviewers = {
+  factCheck: async () => ({ chapter: 1, issues: [] }),
+  editorial: async () => ({ chapter: 1, issues: [] }),
+}
+
+const charCard = `---\n姓名: 林晚\n状态: 在世\n位置: 青云宗\n境界: 练气三层\n---\n## 设定\n玉佩线索。`
+const timeline = '| 章 | 一句话事件 |\n| --- | --- |\n| 1 | 林晚得玉佩 |\n'
+
+/**
+ * 主循环端到端(review 推荐的 P0 锁定测试):
+ * 建书(persistCreateBook 内部 git init) → 备料 → 两审(桩) → 定稿(刷新缓存) → next
+ * 期望 next 报序6 且 nextChapter = 已定稿章 + 1(不重抄)。
+ * 这条在 P0-1 修复前会红:定稿不刷新缓存 → next 仍说 maxChapter=0 → 起草第 1 章。
+ */
+test('主循环:建书→备料→两审(桩)→定稿→next 不重抄最新章', async () => {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-loop-'))
+  const dbDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-loop-db-'))
+  const cache = new CacheManager(path.join(dbDir, 'index.db'))
+  const ctx = { repoPath: root, cache }
+  const git = (a) => exec('git', a, { cwd: root })
+  try {
+    // 1. 建书:persistCreateBook 内部完成 git init + .gitignore + core.quotepath(P0-2)
+    const r1 = await persistCreateBook(ctx, {
+      book: { spec_version: '7.0', 书名: '测', 卷规模: 40, 体检周期: 50 },
+      总纲: '# 总纲\n## 结局\nx',
+      卷纲: '# 第1卷\n入门',
+    })
+    assert.equal(r1.ok, true, r1.error)
+    // 建书产物 + 角色卡 + 时间线 一起入档(避免序2 手改误触)
+    await fs.mkdir(path.join(root, '定稿/设定/角色'), { recursive: true })
+    await fs.writeFile(path.join(root, '定稿/设定/角色/林晚.md'), charCard, 'utf8')
+    await fs.mkdir(path.join(root, '定稿/设定/时间线'), { recursive: true })
+    await fs.writeFile(path.join(root, '定稿/设定/时间线/第01卷.md'), timeline, 'utf8')
+    await git(['config', 'user.email', 't@example.com'])
+    await git(['config', 'user.name', 'test'])
+    await git(['add', '-A'])
+    await git(['commit', '-q', '-m', 'init book'])
+    // .gitignore 真的 ignore 了 .cache / 工作区(P0-2 旁证)
+    const gi = await fs.readFile(path.join(root, '.gitignore'), 'utf8')
+    assert.ok(gi.includes('.cache/') && gi.includes('工作区/'), '.gitignore 应 ignore .cache 与 工作区')
+
+    // 2. next → 序6 起草第 1 章
+    await cache.ensureReady(root)
+    let s = await determineNextState(ctx)
+    assert.equal(s.序, 6, `建书后应序6,实际:${JSON.stringify(s)}`)
+    assert.equal(s.dto.nextChapter, 1)
+
+    // 3. 备料:细纲 + 草稿
+    await persistDraftOutline(ctx, { 细纲: '## 本章要写到的事\n林晚突破练气四层。\n' })
+    await fs.mkdir(path.join(root, '工作区'), { recursive: true })
+    await fs.writeFile(path.join(root, '工作区/草稿-1.md'), '林晚运转功法,突破到练气四层。', 'utf8')
+
+    // 4. 两审(桩)→ 落 审稿.md + 评审报告/
+    const rv = await runReviews(ctx, {
+      chapterNum: 1,
+      draftPath: '工作区/草稿-1.md',
+      mode: 'complete',
+      reviewers: stubReviewers,
+      待确认新专名: [],
+      章摘要: '林晚突破。',
+    })
+    assert.equal(rv.ok, true, rv.errors?.join('; '))
+
+    // 5. 定稿第 1 章(P0-1:定稿后刷新缓存)
+    const fr = await finalizeChapter(ctx, {
+      chapterNum: 1,
+      frontMatter: {
+        章号: 1, 标题: '初露', 卷: 1, 视角: '林晚', 字数: 100,
+        章定位: '推进', 钩子: '危机钩-强', 情绪定位: '铺垫',
+      },
+      body: '林晚运转功法,突破到练气四层。',
+      summary: '林晚突破练气四层。',
+      workspaceFiles: ['草稿-1.md', '细纲.md', '审稿.md'],
+    })
+    assert.equal(fr.ok, true, fr.error)
+
+    // 6. next → 序6 起草第 2 章(P0-1 核心断言:不重抄第 1 章)
+    s = await determineNextState(ctx)
+    assert.equal(s.序, 6, `定稿后应仍序6,实际:${JSON.stringify(s)}`)
+    assert.equal(s.dto.nextChapter, 2, '定稿第1章后 next 应推进到第2章,不应重抄第1章')
+  } finally {
+    await cache.close()
+    await fs.rm(root, { recursive: true, force: true })
+    await fs.rm(dbDir, { recursive: true, force: true })
+  }
+})

+ 33 - 0
v7/test/state-machine/persist.test.js

@@ -3,6 +3,8 @@ import assert from 'node:assert/strict'
 import os from 'node:os'
 import path from 'node:path'
 import { promises as fs } from 'node:fs'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
 import {
   persistRepair,
   persistCreateBook,
@@ -15,6 +17,37 @@ async function tmpRepo() {
   return { ctx: { repoPath: root }, root, cleanup: () => fs.rm(root, { recursive: true, force: true }) }
 }
 const read = (root, rel) => fs.readFile(path.join(root, rel), 'utf8')
+const exec = promisify(execFile)
+
+test('persistCreateBook(P0-2)→ git init + core.quotepath=false + .gitignore(书仓库工程化)', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  try {
+    const r = await persistCreateBook(ctx, {
+      book: { spec_version: '7.0', 书名: '测' },
+      总纲: '# 总纲',
+      卷纲: '# 第1卷',
+    })
+    assert.equal(r.ok, true, r.error)
+    const { stdout: inside } = await exec('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root })
+    assert.equal(inside.trim(), 'true', '建书应 git init 出一个仓库')
+    const { stdout: qp } = await exec('git', ['config', 'core.quotepath'], { cwd: root })
+    assert.equal(qp.trim(), 'false', '应设 core.quotepath=false(spec quality §3.3)')
+    const gi = await read(root, '.gitignore')
+    assert.ok(gi.includes('.cache/') && gi.includes('工作区/'), '.gitignore 应 ignore .cache 与 工作区')
+  } finally { await cleanup() }
+})
+
+test('persistCreateBook(P0-2)→ 已有 .gitignore 追加不覆盖', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  try {
+    await fs.writeFile(path.join(root, '.gitignore'), 'node_modules/\n', 'utf8')
+    const r = await persistCreateBook(ctx, { book: { 书名: '测' }, 总纲: '# 总纲', 卷纲: '# 第1卷' })
+    assert.equal(r.ok, true, r.error)
+    const gi = await read(root, '.gitignore')
+    assert.ok(gi.includes('node_modules/'), '不应覆盖既有条目')
+    assert.ok(gi.includes('.cache/') && gi.includes('工作区/'))
+  } finally { await cleanup() }
+})
 
 test('persistDraftOutline(序6)→ 写 工作区/细纲.md', async () => {
   const { ctx, root, cleanup } = await tmpRepo()

+ 38 - 0
v7/test/storage/adapters/ChapterWriter.test.js

@@ -1,8 +1,13 @@
 import { test } from 'node:test'
 import assert from 'node:assert/strict'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
 import { ChapterWriter } from '../../../src/storage/adapters/ChapterWriter.js'
 import { makeRepo, cleanup, read } from '../_tmprepo.js'
 
+const listChapters = (root) =>
+  fs.readdir(path.join(root, '定稿', '正文')).then((fs_) => fs_.filter((f) => f.endsWith('.md')))
+
 test('ChapterWriter.writeChapter 写出定稿正文(防呆 front matter + 正文)', async () => {
   const root = await makeRepo()
   try {
@@ -39,3 +44,36 @@ test('ChapterWriter.writeChapter 危险值加引号(防呆)', async () => {
     await cleanup(root)
   }
 })
+
+test('ChapterWriter.writeChapter 重写同章改标题 → 删旧文件(P0-3a:不撞 PRIMARY KEY)', async () => {
+  const root = await makeRepo()
+  try {
+    const w = new ChapterWriter(root)
+    await w.writeChapter(5, { 章号: 5, 标题: '旧题', 卷: 1 }, '正文')
+    let files = await listChapters(root)
+    assert.equal(files.length, 1)
+    await w.writeChapter(5, { 章号: 5, 标题: '新题', 卷: 1 }, '正文')
+    files = await listChapters(root)
+    assert.equal(files.length, 1, '重写同章应删旧文件,不留两条同章号 → scanChapters 不再撞 PRIMARY KEY')
+    assert.ok(files[0].includes('新题'))
+  } finally {
+    await cleanup(root)
+  }
+})
+
+test('ChapterWriter.writeChapter 标题含 Windows 非法字符 → 文件名净化不炸写盘(P2-1)', async () => {
+  const root = await makeRepo()
+  try {
+    const w = new ChapterWriter(root)
+    const r = await w.writeChapter(7, { 章号: 7, 标题: 'a:b?c*d"e|f<g>', 卷: 1 }, '正文')
+    assert.equal(r.ok, true, r.error)
+    const files = await listChapters(root)
+    assert.equal(files.length, 1)
+    assert.ok(!/[<>:"/\\|?*]/.test(files[0]), '文件名应剔除 Windows 非法字符')
+    // 标题本体在 front matter 里保留(可能被 YAML 加引号,只断子串)
+    const content = await read(root, `定稿/正文/${files[0]}`)
+    assert.match(content, /a:b\?c\*d/)
+  } finally {
+    await cleanup(root)
+  }
+})