Преглед изворни кода

feat(v7): M4 P0——两审编排子层 + ReviewInput/ReviewIssue DTO(DI 隔离 AI,零真 AI)

- CharacterContext DTO:从 EntityReader 组装,不泄漏文件路径(实施计划 §1.5 原则1)
- 审稿 schema + 阻断规则:两审 category 分域;critical→强制 blocking;unregistered_thread(D3)→强制非阻断;计数复算覆盖 AI 自报
- assembleReviewInput / mergeReviews(完整/降级模式声明)/ persistReviewReport(落盘 工作区/审稿.md + 评审报告/)
- runReviews:DI 注入两审,校验→合并→落盘,零真 AI
- 17 测试绿(全量 217 绿,M1-M3 零回归)

规划三件套:prd/design/implement(M4 不拆,真模型 smoke 推迟;知识迁移 CSV 单源消双表)
lingfengQAQ пре 15 часа
родитељ
комит
abcae92636

+ 1 - 0
.trellis/tasks/06-27-m4-ai-roles/check.jsonl

@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

+ 137 - 0
.trellis/tasks/06-27-m4-ai-roles/design.md

@@ -0,0 +1,137 @@
+# 技术设计:M4 AI 角色层与一级宿主壳
+
+> 前置:已读 实施计划 §M4/§1.5、多智能体 spec v3.4、story-repo-spec 0.8 §8/§2.0/§10/§11、v6 reviewer.md + 知识抽样。
+> 落点全部在 `v7/`(包自包含);AI 协作层只吃 DTO、只吐结构化,落盘走 M2 Writer 小端口。
+
+## 1. 落点布局(v7/ 下,全绿地)
+
+```
+v7/
+├── roles/                      # 角色单源(markdown + frontmatter),真源
+│   ├── 事实审查.md
+│   └── 编辑审.md
+├── skills/
+│   └── webnovel-writer/SKILL.md   # 状态机单入口,平台条件块,真源
+├── references/                 # 知识层平移(逐文件审查后入)
+│   ├── 题材模板/ 追读力/ 爽点节奏/
+│   └── 迁移报告.md
+├── adapters/
+│   ├── registry.json           # 三级分级
+│   ├── claude-code/support.md
+│   └── codex/support.md
+├── templates/AGENTS.md         # 工作目录公约数层模板(标记块)
+├── scripts/build-host-shells.mjs   # 生成器 CLI(薄壳)
+└── src/
+    ├── review/                 # 两审编排 Use Case(确定性,零真 AI)
+    │   ├── index.js            # assembleReviewInput / runReviews / mergeReviews / persistReviewReport
+    │   └── schema.js           # ReviewIssue 校验 + 阻断规则
+    ├── dto/                    # 跨层稳定 DTO 组装器
+    │   ├── chapter-brief.js    # ChapterBrief
+    │   └── character-context.js# CharacterContext
+    ├── session/index.js        # readBooksRegistry / assembleSessionContext / scanRebuildBooks
+    ├── host-shells/generate.js # 生成器逻辑(可单测)+ drift check
+    └── state-machine/persist.js# AI 态产物回流落盘(序0/1/4/6)
+```
+
+**约定**(延续 M1-M3):`scripts/build-host-shells.mjs` 与 `bin/` 同为薄入口,逻辑在 `src/host-shells/`;命令走 `run(args,options,ctx)` 纯返回契约;零第三方依赖。
+
+## 2. 两审编排(R1,确定性核心)
+
+### 2.1 边界:JS 做编排,AI 做审稿
+v7 铁律:状态机/脚本不做业务判断,AI 吃 DTO 吐结构化。两审的"读整章找问题"是 AI 的活;JS 负责**组装输入 + 校验输出 + 合并落盘 + 诚实声明**。用**依赖注入**把 AI 隔在确定性层之外:
+
+```js
+// reviewers 由宿主壳接线(完整=两个 subagent;降级=主 agent 顺序);测试注入假函数→零真 AI
+runReviews(ctx, { chapterNum, draftPath, mode, reviewers })
+//   1. input = assembleReviewInput(...)            // DTO,无文件路径
+//   2. fact = reviewers.factCheck(input)           // AI 出 ReviewIssue[]
+//      edit = reviewers.editorial(input)           // mode='degraded' 时顺序
+//   3. validateReviewReport(fact|edit)             // schema + 阻断规则
+//   4. merged = mergeReviews({fact,edit},{mode})   // 合并 + counts + 诚实声明
+//   5. persistReviewReport(...)                    // 落盘 工作区/审稿.md + 评审报告/
+```
+
+### 2.2 ReviewInput DTO(AI 不见路径)
+`{ 章号, 草稿全文, 本章要写到的事[], 全书近况片段, 相关角色:CharacterContext[], 相关条目(伏笔/悬念/感情线), 时间线片段, 信息差关键词候选[], 名册待确认新专名[] }`。全部经 M1 读接口 + M2 备料组装,值进 DTO,路径不进。
+
+### 2.3 ReviewIssue schema 与阻断规则(src/review/schema.js)
+- 字段:`severity / category / location / description / evidence / fix_hint / blocking`。
+- **分域**:事实审查 category ∈ {setting,timeline,continuity,character,logic,requirement,leak,evidence,unregistered_thread};编辑审 ∈ {structure,pacing,commercial,motivation}。越界拒绝。
+- **阻断规则**:`critical`→`blocking=true`;`unregistered_thread`(D3)→**强制 `blocking=false`**(即兴伏笔非缺陷,只出候选交作者);其余 severity 由 AI 判 blocking。
+- 复核 `issues_count/blocking_count/has_blocking` 与 `issues` 一致(校验器覆盖写回,继承 v6 review-pipeline 复核纪律)。
+
+### 2.4 落盘
+`工作区/审稿.md` = 草稿 + 两审意见 + 待确认新专名 + 章摘要(spec §8 第7步);`工作区/评审报告/{事实审查,编辑审}.json` 原始输出。模式声明写在审稿单头部(降级:"本次兼容模式,审稿隔离度低于完整两审")。
+
+## 3. AI 态 DTO 与落盘(R2)
+
+- `src/dto/`:`CharacterContext`(角色快照,境界/状态/位置/持有,无路径)——`ReviewInput` 与备料复用。`ChapterBrief` 不另造:M2 `工作区/本章写作材料.md` 即 chapter brief(无新消费者,避免冗余 DTO)。
+- `src/state-machine/persist.js`:AI 产物回流落盘,与 `dto.js`(读侧组装)对称:
+  - 序0 修复确认 → 写回修复后的源文件(经 parser 校验)
+  - 序1 建书 → `book.yaml` + `大纲/总纲.md` + 第一卷卷纲(经 Writer)
+  - 序4 卷复盘 → 卷摘要 + 下卷卷纲 + 勾选的伏笔条目
+  - 序6 细纲 → `工作区/细纲.md`
+- 落盘**只经 M2 Writer 小端口**;AI 提交结构化 DTO,M4 落盘,AI 全程不碰文件路径。
+
+## 4. SessionStart 与书单自愈(R3)
+
+- `readBooksRegistry(workdir)`:读 `.webnovel/books.jsonl`(每行一本:书名/目录/是否当前/最后打开);JSON 行损坏跳过并标记。
+- `scanRebuildBooks(workdir)`:缺失/全损 → 扫描含 `book.yaml` 子目录重建(spec §0 可重建);"当前在写"标记缺失时返回 needsAuthorPick。
+- `assembleSessionContext(workdir)`:产"当前在写哪本/共几本/全书近况入口"注入文本。
+- **无 hook 等价**:状态机入口启动调同一函数 → 与 Claude Code SessionStart hook 注入文本逐字一致(测试断言等价)。
+- **写侧不做**:books.jsonl 写入/换书对话属 M5;M4 只读 + 重建。
+
+## 5. 宿主壳生成器 + drift check(R4)
+
+### 5.1 生成器(src/host-shells/generate.js)
+- 读 `roles/`、`skills/`、`adapters/registry.json` → `dist/<host>/`(agent 壳按平台:Claude Code md+frontmatter / Codex TOML / Gemini md;编译后 SKILL.md)。
+- **平台条件块**(§5.9):零依赖手写最小渲染器——`{{#if agentCapable}}…{{/if}}`、`{{#if hasHooks}}…{{/if}}`、变量插值(命令引用语法/路径)。布尔集只留 `agentCapable`/`hasHooks`(不维护大能力矩阵)。
+- **降级编译进生成物**:无 subagent 平台,生成的 SKILL.md 正文就是顺序执行版 + 兼容声明(不靠运行时判断)。
+- 生成物 `dist/` 不提交(§6.2)。
+
+### 5.2 drift check = 确定性(spec v3.4 §6.2)
+`build-host-shells --check`:同输入连跑两次,断言**逐字节一致**(determinism)+ 生成物过 validator。CI 必跑。(dist 不提交,故非"对比已提交生成物",而是确定性验证。)
+
+### 5.3 package validator
+校验:registry schema、逐宿主 `support.md` 存在、`description` ≤ Codex 8k 预算、生成物**无本机绝对路径**、roles frontmatter 完整。
+
+### 5.4 角色单源:重构而非拷贝 v6
+`roles/事实审查.md`、`roles/编辑审.md` 任务书:
+- **吃 `ReviewInput` DTO**,声明输出 §8 schema;**不含** `python`/脚本调用/文件路径读取(对照 AC6 grep)。
+- 事实审查维度 = v6 五维(setting/timeline/continuity/character/logic)+ v7 新增(requirement 要写到的事核对 / leak 泄密候选判断 / evidence 履历证据验证 / unregistered_thread D3)。
+- 编辑审 = structure/pacing/commercial/motivation;明确排除"自由评文笔好坏"。
+
+## 6. 知识层平移(R5,逐文件审查 + 单一真源)
+
+- 范围严守 spec §11 平移表:**题材模板 / 追读力分类 / 爽点与节奏知识库**,其余 v6 references 不迁。
+- **先选真源,后迁移(用户指令:题材以 CSV 最新版为准,不维护双表)**。v6 同一知识体有新旧两层(新 CSV `references/csv/`+`taxonomy/` vs 老 markdown `skills/*/references/`+`references/genre-profiles.md`),重叠即"两张表"——v7 每体只留一份:
+
+  | 知识体 | v7 唯一真源(取) | v6 弃/折叠 |
+  |---|---|---|
+  | 题材模板 | `taxonomy/genre-index.csv` + `csv/题材与调性推理.csv` | `genre-profiles.md`、`skills/.../genre-tropes.md`、`anti-trope-*.md`(独有内容折叠进 CSV,否则丢) |
+  | 爽点与节奏 | `csv/爽点与节奏.csv` | `genre-hook-payoff-library.md`、`pacing-control.md` 重复部分 |
+  | 追读力分类 | `references/reading-power-taxonomy.md` | (唯一源,无双表) |
+
+- 流程:**P4.1 产真源选定表** → 逐份真源清 v6 问题 → 入 `v7/references/<分类>/` → `迁移报告.md` 记"选谁/弃谁/为什么 + 逐文件改了什么"。
+- **v6 问题清单(grep 把关,AC7)**:
+  | 类别 | 命中样例 | 处置 |
+  |---|---|---|
+  | 双表 | 题材同时存在 CSV 与 markdown 表 | **只留 CSV**,markdown 独有内容折叠进 CSV |
+  | 旧路径 | `设定集/`、`正文/`(非"定稿/正文")、`.webnovel/state.json` | 改 v7 路径或删 |
+  | 退场术语 | "卡"(卡点义)、"棘轮" | 换 spec 术语 |
+  | v6 机制 | `state.json`、doctor、dashboard、v6 skill 名(webnovel-write/plan…)、`python webnovel.py`;CSV 的 `适用技能`/`大模型指令` 列、跨 CSV `推荐检索表` 列 | 删/重写为 v7 中性 |
+  | 反模式 | "评文笔好坏""打分" | 删(两审职责排除) |
+- craft 内容(钩子/兑现/节奏/题材原型/调性/毒点)架构无关,保留。CSV 保持 CSV 形态(机器友好、即单源),不另起 markdown 表复刻。
+- `genre-index.csv` 的 `template_file` 列引用 `templates/genres/*.md`:迁移时一并核对引用完整性(引用的模板要么迁入要么清列),不留悬空引用。
+
+## 7. 取舍
+
+- **DI 注入 reviewers**:把 AI 隔在确定性层外,P0-P5 主体全可 TDD/CI 绿;真模型只在推迟的 smoke 接线。代价:多一层注入接口——值,换来零真 AI 的可测性。
+- **drift = 确定性**(非比对已提交物):符合 v3.4"dist 不提交";代价:不防"手改已分发壳",但分发由 M5 安装器哈希追踪兜底。
+- **角色重构而非拷贝**:这是"不带 v6 问题进 v7"的核心落点;代价:多写,不能 copy——必须,v6 reviewer 直调 runtime 违反铁律。
+- **资产放 v7/ 下**(非仓库根):包自包含,安装器(M5)从 v7/ 取源分发。
+
+## 8. 回滚点
+
+- 各 P 独立、全为**新增目录/文件**(`roles/`、`skills/`、`references/`、`adapters/`、`scripts/`、`src/{review,dto,session,host-shells}`);对 M1-M3 唯一改动是 `state-machine/persist.js` 新增(不改 dto.js 读侧)。
+- 未提交前 `git restore v7/<子目录>` 即回滚;知识迁移是纯新增,删 `references/` 即净。

+ 1 - 0
.trellis/tasks/06-27-m4-ai-roles/implement.jsonl

@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

+ 98 - 0
.trellis/tasks/06-27-m4-ai-roles/implement.md

@@ -0,0 +1,98 @@
+# 执行计划:M4 AI 角色层与一级宿主壳
+
+> 前置:已读 prd.md / design.md。落点 `v7/{roles,skills,references,adapters,templates,scripts,src/{review,dto,session,host-shells,state-machine}}`。
+> 本机:`cd v7 && PYTHONUTF8=1 node --test`;零依赖延续;命令走 `run(args,options,ctx)` 契约。
+> 工作方式:不开子代理;TDD(先红后绿);诚实分期 commit;**真模型 smoke 推迟**(R6)。
+
+## 分期(M4 不拆,一任务 P0-P5)
+
+```
+P0 两审编排子层(R1)+ DTO 组装器(src/dto/)           ← 确定性核心,DI 注入,零真 AI
+   ↓
+P1 AI 态落盘契约(R2)+ SessionStart 注入(R3)
+   ↓
+P2 角色单源 roles/ + skills/SKILL.md + AGENTS.md + support.md(R4 撰写面,重构非拷贝)
+   ↓
+P3 生成器 + drift check + registry + validator(R4 JS 面,吃 P2 源)
+   ↓
+P4 知识层平移(R5,逐文件审查清 v6 遗毒 + 迁移报告)
+   ↓
+P5 AC 复核 + CI 双平台;真模型 smoke 推迟文档
+```
+
+## P0 两审编排子层 + DTO 组装器(R1)
+
+- [x] P0.1 `src/dto/character-context.js`:从 M1 EntityReader 组装,DTO 无文件路径。测试断言字段齐全 + 无路径泄漏 + 角色不存在不抛(3 绿)。(`ChapterBrief` 不另造:M2 本章写作材料.md 即 chapter brief)
+- [ ] P0.2 `src/review/schema.js`:`validateReviewIssue` + 阻断规则(critical→blocking;unregistered_thread→恒非阻断;category 分域越界拒)。先红:构造越界/critical/unregistered_thread 样例。
+- [ ] P0.3 `src/review/index.js`:`assembleReviewInput`(ReviewInput DTO)、`mergeReviews`(合并 + counts + 模式声明)、`persistReviewReport`(落盘 `工作区/审稿.md` + `评审报告/`)。
+- [ ] P0.4 `runReviews(ctx,{chapterNum,draftPath,mode,reviewers})`:DI 注入 reviewers;`mode='degraded'` 输出含兼容声明。测试注入假 reviewers(零真 AI)。
+- [ ] P0.5 `test/review/`:完整/降级双模式、schema 阻断规则、D3 候选非阻断、落盘内容、DTO 无路径。
+
+**验证 P0**:`node --test test/review/ test/dto/` 全绿
+**提交 P0**:`feat(v7): M4 P0——两审编排子层 + ReviewInput/ReviewIssue DTO(DI 隔离 AI)`
+
+## P1 AI 态落盘契约 + SessionStart(R2/R3)
+
+- [ ] P1.1 `src/state-machine/persist.js`:`persistRepair/persistCreateBook/persistVolumeReview/persistDraftOutline`——吃 AI 结构化 DTO,只经 M2 Writer 落盘。先红:断言落盘文件内容 + 零路径泄漏给 AI。
+- [ ] P1.2 `src/session/index.js`:`readBooksRegistry`(损坏行跳过)、`scanRebuildBooks`(扫 book.yaml 重建)、`assembleSessionContext`(注入文本)。
+- [ ] P1.3 无 hook 等价:状态机入口调 `assembleSessionContext` 与 hook 注入逐字一致(测试断言)。
+- [ ] P1.4 `test/state-machine/persist.test.js`、`test/session/`:各 AI 态落盘、books.jsonl 读/重建/缺当前书、等价路径。
+
+**验证 P1**:`node --test test/state-machine/ test/session/` 全绿
+**提交 P1**:`feat(v7): M4 P1——AI 态产物落盘契约 + SessionStart 注入与书单自愈`
+
+## P2 角色单源 + 宿主壳源(R4 撰写面)
+
+- [ ] P2.1 `roles/事实审查.md`:吃 ReviewInput DTO,五维 + v7 四项(requirement/leak/evidence/unregistered_thread),输出 §8 schema;**无 python/脚本/文件路径**(对照 AC6)。
+- [ ] P2.2 `roles/编辑审.md`:structure/pacing/commercial/motivation;排除"评文笔好坏"。
+- [ ] P2.3 `skills/webnovel-writer/SKILL.md`:状态机单入口 + 平台条件块(agentCapable/hasHooks)+ 降级顺序版编译块;description ≤ Codex 8k。
+- [ ] P2.4 `templates/AGENTS.md`(标记块)、`adapters/registry.json`(三级)、`adapters/{claude-code,codex}/support.md`(官方链接+核验日期+支持情况+降级+smoke 命令)。
+
+**验证 P2**:人工评审 + grep 断言(roles 无 `python`/路径);留给 P3 生成器/validator 机检
+**提交 P2**:`feat(v7): M4 P2——两审角色单源(DTO 化重构)+ SKILL.md 单入口 + registry/support`
+
+## P3 生成器 + drift check + validator(R4 JS 面)
+
+- [ ] P3.1 `src/host-shells/generate.js`:读 roles/skills/registry → 各平台壳;手写条件块渲染器(零依赖)。先红:fixture 源 → 期望产物。
+- [ ] P3.2 drift check:`--check` 同输入连跑两次逐字节一致 + 生成物过 validator。
+- [ ] P3.3 `src/host-shells/validator.js`:registry schema / support.md 存在 / description 长度 / 无绝对路径 / roles frontmatter。
+- [ ] P3.4 `scripts/build-host-shells.mjs`:薄 CLI(`--target all|<host>`、`--check`)。
+- [ ] P3.5 `test/host-shells/`:生成 Claude Code/Codex 壳、降级编译、drift 确定性、validator 各拒绝项。
+- [ ] P3.6 CI:`.github/workflows/v7-ci.yml` 加 `build-host-shells --check` + validator 步骤(双平台)。
+
+**验证 P3**:`node --test test/host-shells/` + `node scripts/build-host-shells.mjs --check` 全绿
+**提交 P3**:`feat(v7): M4 P3——宿主壳生成器 + drift check(确定性)+ package validator + CI`
+
+## P4 知识层平移(R5,真源选定 + 逐文件审查)
+
+- [ ] P4.1 **真源选定表**(design §6):为题材/爽点节奏/追读力各定唯一真源——题材取 `genre-index.csv`+`题材与调性推理.csv`(弃 `genre-profiles.md`/老 `genre-tropes.md`/`anti-trope-*.md`),爽点节奏取 `爽点与节奏.csv`,追读力取 `reading-power-taxonomy.md`;写进 `references/迁移报告.md`(选谁/弃谁/为什么)。
+- [ ] P4.2 **逐份真源**:清 v6 双表/旧路径/退场术语/v6 机制(含 CSV `适用技能`/`大模型指令`/`推荐检索表` 列)/反模式(design §6 表),markdown 独有内容折叠进 CSV → 入 `v7/references/<分类>/` → 报告记"改了什么+为什么"。
+- [ ] P4.3 核对 `genre-index.csv` 的 `template_file` 引用:引用的 `templates/genres/*.md` 要么迁入要么清列,不留悬空。
+- [ ] P4.4 `test/references/migration.test.js`:grep 断言迁移文件零 `python webnovel.py`/`设定集/`/`state.json`/v6 skill 名/"评文笔"/CSV v6 运行时列;**断言题材在 v7 仅一份真源(无并行 markdown 题材表)**;迁移报告覆盖每个迁入文件 + 含真源选定表。
+
+**验证 P4**:`node --test test/references/` 全绿 + 人工复读迁入文件
+**提交 P4**:`feat(v7): M4 P4——知识层平移(题材CSV单源/追读力/爽点节奏,清 v6 遗毒+消双表)`
+
+## P5 AC 复核 + CI(R6)
+
+- [ ] P5.1 全量 `node --test` 绿;过 prd AC1-AC8。
+- [ ] P5.2 不变量回归:删缓存重建、定稿原子、git 隐身仍绿。
+- [ ] P5.3 推送验证 CI 双平台(drift check + validator 在 Windows 跑)。
+- [ ] P5.4 真模型 smoke **推迟**:在 `adapters/*/support.md` 标注 smoke 命令 + 在 prd/memory 记为 beta 手测门(Claude Code + Codex 建书→写1章→两审→定稿),不阻断本任务。
+
+**提交 P5**:`feat(v7): M4 P5——AC 复核 + CI 双平台(真模型 smoke 推迟 beta)`
+
+## 出口判据(对齐 prd Acceptance)
+
+- [ ] AC1-AC8 全绿(两审编排/schema 阻断/AI 态落盘/SessionStart/生成器 drift/角色 DTO 化/知识净化/不破坏 M1-M3)
+- [ ] CI 双平台绿
+- [ ] 真模型 smoke 推迟项已文档化
+
+## 后续(M4 完成后,单独任务)
+
+- **M1-M4 全量 review**:通审四个里程碑代码 + 全部迁移产物("不带 v6 问题进 v7"复查);用户明确要求。
+
+## 回滚点
+
+- 各 P 独立、几乎全为新增目录;对 M1-M3 唯一改动 `state-machine/persist.js`(新增)。
+- 未提交前 `git restore v7/<子目录>`;知识迁移纯新增,删 `references/` 即净。

+ 94 - 0
.trellis/tasks/06-27-m4-ai-roles/prd.md

@@ -0,0 +1,94 @@
+# M4 AI 角色层与一级宿主壳
+
+## Goal
+
+让 v7 第一次"能被 AI 宿主端到端跑起来":落地状态机单入口 SKILL.md、两审角色单源、宿主壳生成器,把 M1-M3 已备好的脚本面与 DTO 缝接上 AI 协作层。**AI 永远只吃 DTO、只吐结构化输出,永不接触文件结构**(实施计划 §1.5 核心原则 1)。
+
+用户决策(2026-06-27):
+- **M4 不拆**,一个任务分期做。
+- **真模型 smoke 先不管**——推迟为后续手测门,不阻断 M4 任务完成。
+- **知识层迁移逐文件审查**——v6 可能带的问题绝不带进 v7。
+- M4 做完**对 M1-M4 全量 review**(单独后续任务)。
+
+## Background(confirmed facts)
+
+- 范围权威:实施计划 §M4 + 多智能体适配 spec v3.4 + story-repo-spec 0.8 §8(两审/D3)/§2.0(工作目录/books.jsonl)/§10(状态机)。
+- v7 现状:`v7/src/{storage,cache,commands,state-machine,prep,mechanical-check,finalize}` 已成型;**无** `roles/`、`skills/`、`references/`、`adapters/`(全绿地);`v7/src/installer/` 是占位(安装器属 M5)。
+- M2/M3 已留缝:`finalize` 定稿原子 commit、`state-machine/dto.js`(序0/1/4/6 DTO);两审编排在 v7 是绿地,`ReviewInput` 无现成缝。
+- **两审权威定义**(spec §8 第6步):
+  - 事实审查 category:`setting/timeline/continuity/character/logic/requirement/leak/evidence/unregistered_thread`
+  - 编辑审 category:`structure/pacing/commercial/motivation`
+  - severity:`critical/high/medium/low`;`critical` 自动 `blocking=true`;`unregistered_thread`(D3)**恒 `blocking=false`**
+  - 第7步审稿单 = 草稿 + 两审意见 + 待确认新专名 + 章摘要;作者:接受 / 改完接受 / 打回
+- **v6 reviewer.md 是关键"遗毒"样本**:只有事实审查 5 维、且直接 `python webnovel.py ... state get-entity` 调 v6 runtime + 直接读文件——违反 v7 铁律。v7 两审角色是**重构,不是拷贝**。
+- **知识迁移范围**(spec §11 平移表 + line 499):只有 **题材模板 / 追读力分类 / 爽点与节奏知识库** 平移继承(喂细纲与两审);其余 v6 references 不在 M4 范围。
+- 知识"遗毒"已抽样确认:`genre-hook-payoff-library.md` craft 内容架构无关可留,但带 v6 skill 名"webnovel-write Step 1 内置合同"需清。
+- SessionStart 缝:`工作目录/.webnovel/books.jsonl`(机器域,作者不手编);有 hook 宿主自动注入,无 hook 宿主由状态机入口读同一文件行为等价;书单可由扫描含 `book.yaml` 子目录重建(spec §0/§2.0)。
+
+## Requirements
+
+### R1 两审编排脚本子层(确定性核心,零真 AI)
+- `assembleReviewInput(ctx, {chapterNum, draftPath})`:从 M1 读接口 + M2 备料 + 细纲组装两审共享的 `ReviewInput` DTO(AI 不见文件路径)。
+- `runReviews(ctx, reviewInput, {mode})`:`mode='complete'`(独立 subagent,各自新鲜上下文)/`'degraded'`(单上下文顺序);输出**必须**含诚实模式声明(降级时"审稿隔离度低于完整两审模式")。
+- 审稿单 schema 校验器:`ReviewIssue`(severity/category/location/description/evidence/fix_hint/blocking)+ 阻断规则(critical→blocking;unregistered_thread→恒非阻断;category 取值受两审分域约束)。
+- 合并两审输出 + 落盘:`工作区/审稿.md`(草稿 + 两审意见 + 待确认新专名 + 章摘要)+ `工作区/评审报告/` 原始输出。
+- D3 未登记伏笔候选检测缝(`unregistered_thread`,恒非阻断,只出候选)。
+
+### R2 AI 态 DTO 与落盘契约(AI 永不碰文件)
+- 扩 `state-machine/dto.js`:为 AI 态(序0/1/4/6)补**产物回流落盘**函数——建书(book.yaml+总纲+卷纲)、修复确认(写回)、卷复盘(卷摘要+下卷卷纲+伏笔机会)、细纲(工作区/细纲.md)。
+- 补 `CharacterContext` DTO 组装器(供两审/写章消费,无路径)。`ChapterBrief` **不另造**:M2 备料产物 `工作区/本章写作材料.md` 即 chapter brief,再建独立 DTO 是冗余(无消费者)。
+- 落盘一律走 M2 Writer 小端口,DTO 进 / 结构化出,M4 落盘、AI 不碰文件。
+
+### R3 SessionStart 注入与书单自愈
+- `readBooksRegistry(workdir)`:读 `.webnovel/books.jsonl`;损坏/缺失 → 扫描含 `book.yaml` 子目录重建书单。
+- `assembleSessionContext(workdir)`:组装"当前在写哪本 / 共几本 / 全书近况入口"注入文本。
+- 无 hook 等价路径:状态机入口可调同一函数,行为与 hook 注入一致。
+
+### R4 角色单源 + 宿主壳生成器 + drift check
+- `roles/事实审查.md` + `roles/编辑审.md`:单源任务书,**吃 ReviewInput DTO**(不调脚本、不读文件),输出 §8 schema;维度对齐 spec(事实审查含 v7 新增四项,编辑审四维)。
+- `skills/<状态机入口>/SKILL.md`:状态机单入口,平台条件块(有无 subagent/hook);**降级路径编译进生成物**(无 subagent 平台生成顺序执行版 + 兼容声明)。
+- `scripts/build-host-shells`(Node ≥22):读 `roles/`+`skills/`+`adapters/registry.json` → 各平台壳;`--check` = drift check(同输入必同输出),进 CI。
+- `adapters/registry.json`(三级分级)+ package validator(registry schema / 逐宿主 support.md 存在 / description ≤ Codex 8k 预算 / 生成物无本机绝对路径)。
+- `AGENTS.md` 公约数层(`<!-- WEBNOVEL:START/END -->` 标记块)+ `adapters/{claude-code,codex}/support.md`(官方链接+核验日期+支持情况+降级策略+smoke 命令)。
+
+### R5 知识层平移(逐文件审查,清 v6 遗毒,单一真源不维护双表)
+- 迁移 **题材模板 / 追读力分类 / 爽点与节奏知识库** 至 v7 `references/`。
+- **真源选定优先(用户指令 2026-06-27:题材以 CSV 最新版为准,不要像 v6 维护两张表)**——迁移前先为每个知识体定**唯一真源**,识别 v6 双表/多表只留最新那份:
+  - 题材 → `references/taxonomy/genre-index.csv` + `references/csv/题材与调性推理.csv`(**CSV 权威**);v6 重复的 `references/genre-profiles.md`、老 `skills/.../genre-tropes.md`、`anti-trope-*.md` **不作为并行表迁入**——独有内容折叠进 CSV,否则丢弃。
+  - 爽点与节奏 → `references/csv/爽点与节奏.csv`(CSV 权威);老 markdown(`genre-hook-payoff-library.md`、`pacing-control.md`)重复部分不并行迁。
+  - 追读力分类 → `references/reading-power-taxonomy.md`(唯一源)。
+  - **v7 每个知识体只有一份真源**,杜绝 CSV+markdown 双表漂移。
+- **每份真源逐条审查**,清除 v6 问题后才入 v7:
+  - v6 旧路径(`设定集/`→`定稿/设定/`、`正文/`→`定稿/正文/`、`.webnovel/state.json` 等)
+  - 退场术语(spec 术语表:"卡""棘轮"退场;统一"定稿/写章流程/全书近况/两审/细纲/审稿/吃书"等)
+  - v6 机制引用(state.json/doctor/dashboard/v6 skill 名/`python webnovel.py`/CSV 的 `适用技能`、`大模型指令`、跨 CSV `推荐检索表` 等 v6 运行时列)
+  - 反模式("自由评文笔好坏"——两审职责明确排除)
+- 产出 `references/迁移报告.md`:先列**真源选定表**(每个知识体:选了谁/弃了谁/为什么),再逐文件记"改了什么 + 为什么"。
+
+### R6 出口与验证
+- 全量 `node --test` 绿;drift check + package validator + 两审编排 + DTO/落盘 + SessionStart 全有测试。
+- CI 双平台绿(含 Windows;drift check 与 validator 在 Windows 跑)。
+- **真模型 smoke 推迟**:文档标注为 M4→beta 的手测门(Claude Code + Codex 建书→写1章→两审→定稿),不阻断本任务。
+
+## Acceptance Criteria
+
+- [ ] AC1 两审编排:伪造草稿 → `assembleReviewInput` 产 DTO → `runReviews` 完整/降级双模式,降级模式输出含兼容声明;审稿单落盘 `工作区/审稿.md` + `评审报告/`(测试覆盖)。
+- [ ] AC2 schema 与阻断规则:`critical`→`blocking=true`;`unregistered_thread`→恒 `blocking=false`;事实审查/编辑审 category 越界被拒(测试覆盖)。
+- [ ] AC3 AI 态落盘:序0/1/4/6 各有"DTO 进 → 结构化产物 → M4 落盘"测试,落盘只经 Writer 小端口,过程零文件路径泄漏给 AI(测试覆盖)。
+- [ ] AC4 SessionStart:`books.jsonl` 正常读注入;损坏/缺失时扫描重建;无 hook 等价路径产出同一注入文本(测试覆盖)。
+- [ ] AC5 生成器与 drift:`build-host-shells --target all` 产三平台壳;`--check` 同输入同输出(CI 必跑);validator 校验 registry/support.md/description 长度/无绝对路径(测试覆盖)。
+- [ ] AC6 角色单源 DTO 化:`roles/*.md` 不含任何 `python`/脚本调用/文件路径读取,只声明吃 ReviewInput DTO + 输出 §8 schema(评审 + grep 断言)。
+- [ ] AC7 知识迁移净化与单一真源:每个知识体在 v7 只有一份真源(题材=CSV,无并行 markdown 题材表);迁移文件零 v6 旧路径/退场术语/v6 机制引用(含 CSV `适用技能`/`大模型指令` 列)/"自由评文笔"反模式;`迁移报告.md` 含真源选定表 + 逐文件变更(评审 + grep 断言)。
+- [ ] AC8 不破坏 M1/M2/M3 不变量:删缓存重建、定稿原子、git 隐身仍全绿;CI 双平台绿。
+
+## Out of Scope
+
+- 真模型 smoke(推迟为 beta 手测门,R6)。
+- npx 安装器 `init`/`update`、`books.jsonl` 写入/换书对话、模板哈希追踪(全属 **M5**;M4 只做 books.jsonl **读侧** + 扫描重建)。
+- 自动模式/按批次定稿(M6)。
+- 导出/`/migrate`(M7)。
+- 二/三级宿主(Gemini/Cursor)亲测(M4 只做一级 Claude Code + Codex 单源 + support.md,真测在 smoke 推迟段)。
+
+## Open Questions
+
+- 无阻断性问题。知识迁移范围若需扩到其余 v6 references,由后续任务追加(本任务严守 spec 平移表三项)。

+ 26 - 0
.trellis/tasks/06-27-m4-ai-roles/task.json

@@ -0,0 +1,26 @@
+{
+  "id": "m4-ai-roles",
+  "name": "m4-ai-roles",
+  "title": "M4 AI 角色层与一级宿主壳",
+  "description": "",
+  "status": "in_progress",
+  "dev_type": null,
+  "scope": null,
+  "package": null,
+  "priority": "P2",
+  "creator": "codex",
+  "assignee": "codex",
+  "createdAt": "2026-06-27",
+  "completedAt": null,
+  "branch": null,
+  "base_branch": "v7",
+  "worktree_path": null,
+  "commit": null,
+  "pr_url": null,
+  "subtasks": [],
+  "children": [],
+  "parent": null,
+  "relatedFiles": [],
+  "notes": "",
+  "meta": {}
+}

+ 30 - 0
v7/src/dto/character-context.js

@@ -0,0 +1,30 @@
+import { EntityReader } from '../storage/adapters/EntityReader.js'
+
+/**
+ * 组装角色上下文 DTO(CharacterContext)。AI 永不见文件结构(实施计划 §1.5 原则 1):
+ * 只返回领域字段(境界/状态/位置/持有…),不含任何文件路径。
+ * @param {{repoPath: string, cache?: object}} ctx
+ * @param {string} name 正名
+ * @returns {Promise<{ok: boolean, context: object|null, error: string}>}
+ */
+export async function assembleCharacterContext(ctx, name) {
+  const reader = new EntityReader(ctx.repoPath, ctx.cache)
+  const r = await reader.readCharacterFrontMatter(name)
+  if (!r.ok) {
+    return { ok: false, context: null, error: r.error }
+  }
+  const fm = r.data || {}
+  return {
+    ok: true,
+    context: {
+      正名: fm.姓名 || name,
+      别名: fm.别名 || [],
+      状态: fm.状态 ?? null,
+      位置: fm.位置 ?? null,
+      境界: fm.境界 ?? null,
+      持有: fm.持有 || [],
+      最后变更章: fm.最后变更章 ?? null,
+    },
+    error: '',
+  }
+}

+ 170 - 0
v7/src/review/index.js

@@ -0,0 +1,170 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { assembleBookStatus } from '../prep/book-status.js'
+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 { validateReviewReport } from './schema.js'
+
+const 兼容声明 = '本次使用兼容模式(单上下文顺序审稿),审稿隔离度低于完整两审模式。'
+const 完整声明 = '完整两审模式(事实审查/编辑审各自独立上下文)。'
+
+/**
+ * 组装两审共享的 ReviewInput DTO(AI 不见文件路径,实施计划 §1.5 原则 1)。
+ * 复用 M1 读接口 + M2 全书近况 + 细纲「要写到的事」。
+ * @param {{repoPath, cache}} ctx
+ * @param {{chapterNum: number, draftPath: string}} args
+ */
+export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
+  try {
+    const { repoPath, cache } = ctx
+
+    const 草稿全文 = await fs.readFile(path.join(repoPath, draftPath), 'utf8')
+
+    let 本章要写到的事 = '(无细纲)'
+    try {
+      const outline = await fs.readFile(path.join(repoPath, '工作区', '细纲.md'), 'utf8')
+      本章要写到的事 = extractSection(outline, '本章要写到的事') || '(细纲未声明)'
+    } catch {
+      // 无细纲
+    }
+
+    const status = await assembleBookStatus(ctx)
+    const 当前卷 = status.ok ? status.data.当前卷 : 1
+
+    // 相关角色:扫角色目录,正名出现在草稿里的纳入(不依赖缓存 schema)
+    const 相关角色 = []
+    try {
+      const dir = path.join(repoPath, '定稿', '设定', '角色')
+      for (const f of await fs.readdir(dir)) {
+        if (!f.endsWith('.md')) continue
+        const name = f.replace(/\.md$/, '')
+        if (草稿全文.includes(name)) {
+          const cc = await assembleCharacterContext(ctx, name)
+          if (cc.ok) 相关角色.push(cc.context)
+        }
+      }
+    } catch {
+      // 无角色目录
+    }
+
+    const tl = await new TimelineReader(repoPath, cache).readVolumeRange(Math.max(1, 当前卷 - 1), 当前卷)
+    const 时间线片段 = tl.ok ? tl.timeline.map((row) => ({ 章: row.章 ?? '', 事件: row.一句话事件 ?? '' })) : []
+
+    const secrets = await new SecretReader(repoPath, cache).listUnrevealed()
+    const 信息差候选 = secrets.map((s) => ({ id: s.id, 关键词: s.关键词 ?? s.keyword ?? '' }))
+
+    return {
+      ok: true,
+      input: {
+        章号: chapterNum,
+        草稿全文,
+        本章要写到的事,
+        全书近况: status.ok ? status.markdown : '',
+        相关角色,
+        时间线片段,
+        信息差候选,
+      },
+      error: '',
+    }
+  } catch (err) {
+    return { ok: false, input: null, error: `组装审稿输入失败:${err.message}` }
+  }
+}
+
+/**
+ * 合并两审输出 → 审稿单结构。已假定各报告经 schema 复算。
+ * @param {{factCheck: object, editorial: object}} reports
+ * @param {{mode: 'complete'|'degraded', chapterNum: number}} opts
+ */
+export function mergeReviews({ factCheck, editorial }, { mode, chapterNum }) {
+  const fIssues = factCheck?.issues || []
+  const eIssues = editorial?.issues || []
+  const issues = [...fIssues, ...eIssues]
+  const blocking_count = issues.filter((x) => x.blocking).length
+  return {
+    章号: chapterNum,
+    mode,
+    模式声明: mode === 'degraded' ? 兼容声明 : 完整声明,
+    事实审查: factCheck,
+    编辑审: editorial,
+    issues,
+    issues_count: issues.length,
+    blocking_count,
+    has_blocking: blocking_count > 0,
+  }
+}
+
+/**
+ * 落盘审稿单(§8 第7步:草稿+两审意见+待确认新专名+章摘要)与原始评审报告。
+ * @param {{repoPath}} ctx
+ * @param {{chapterNum, merged, draft, 待确认新专名?: string[], 章摘要?: string}} args
+ */
+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
+        .map((i) => `- [${i.severity}/${i.category}${i.blocking ? '/阻断' : ''}] ${i.location}:${i.description}(修复:${i.fix_hint})`)
+        .join('\n')
+    : '(无问题)'
+
+  const md = [
+    `# 第 ${chapterNum} 章审稿单`,
+    '',
+    `> ${merged.模式声明}`,
+    `> 共 ${merged.issues_count} 个问题:${merged.blocking_count} 阻断。`,
+    '',
+    '## 两审意见',
+    issueLines,
+    '',
+    '## 待确认新专名',
+    待确认新专名.length ? 待确认新专名.map((n) => `- ${n}`).join('\n') : '(无)',
+    '',
+    '## 章摘要(扫一眼可改)',
+    章摘要 || '(无)',
+    '',
+    '## 草稿',
+    draft,
+    '',
+  ].join('\n')
+
+  const 审稿路径 = path.join(工作区, '审稿.md')
+  await fs.writeFile(审稿路径, md, 'utf8')
+  return { ok: true, 审稿路径, error: '' }
+}
+
+/**
+ * 两审编排:组装输入 → DI 注入两审 → 校验 → 合并 → 落盘。零真 AI(reviewers 由宿主壳/测试注入)。
+ * @param {{repoPath, cache}} ctx
+ * @param {{chapterNum, draftPath, mode, reviewers, 待确认新专名?, 章摘要?}} args
+ */
+export async function runReviews(ctx, { chapterNum, draftPath, mode = 'complete', reviewers, 待确认新专名, 章摘要 }) {
+  const inp = await assembleReviewInput(ctx, { chapterNum, draftPath })
+  if (!inp.ok) return { ok: false, errors: [inp.error] }
+
+  const rawFact = await reviewers.factCheck(inp.input)
+  const rawEdit = await reviewers.editorial(inp.input)
+
+  const vFact = validateReviewReport(rawFact, { reviewType: 'factCheck' })
+  const vEdit = validateReviewReport(rawEdit, { reviewType: 'editorial' })
+  const errors = [...vFact.errors, ...vEdit.errors]
+  if (errors.length) return { ok: false, errors }
+
+  const merged = mergeReviews({ factCheck: vFact.report, editorial: vEdit.report }, { mode, chapterNum })
+  const saved = await persistReviewReport(ctx, {
+    chapterNum,
+    merged,
+    draft: inp.input.草稿全文,
+    待确认新专名,
+    章摘要,
+  })
+  return { ok: true, merged, 审稿路径: saved.审稿路径, errors: [] }
+}

+ 55 - 0
v7/src/review/schema.js

@@ -0,0 +1,55 @@
+/**
+ * 审稿单 schema 与阻断规则(多智能体 spec v3.4 §5.4 / story-repo-spec §8 第6步)。
+ * 两审 category 分域;阻断规则:critical→强制 blocking;unregistered_thread(D3)→强制非阻断。
+ * 报告级计数(issues_count/blocking_count/has_blocking)由校验器复算覆盖,不信 AI 自报。
+ */
+export const FACT_CATEGORIES = [
+  'setting', 'timeline', 'continuity', 'character', 'logic',
+  'requirement', 'leak', 'evidence', 'unregistered_thread',
+]
+export const EDIT_CATEGORIES = ['structure', 'pacing', 'commercial', 'motivation']
+export const SEVERITIES = ['critical', 'high', 'medium', 'low']
+
+const REQUIRED_FIELDS = ['location', 'description', 'evidence', 'fix_hint']
+
+/**
+ * @param {object} report AI 出的审稿单 {chapter, issues:[...]}
+ * @param {{reviewType: 'factCheck'|'editorial'}} opts
+ * @returns {{ok: boolean, report: object|null, errors: string[]}}
+ */
+export function validateReviewReport(report, { reviewType } = {}) {
+  const errors = []
+  const allowed =
+    reviewType === 'factCheck' ? FACT_CATEGORIES : reviewType === 'editorial' ? EDIT_CATEGORIES : null
+  if (!allowed) errors.push(`未知审查类型:${reviewType}`)
+
+  if (!report || !Array.isArray(report.issues)) {
+    return { ok: false, report: null, errors: [...errors, 'report.issues 缺失或非数组'] }
+  }
+
+  const normalized = report.issues.map((issue, i) => {
+    const where = `issues[${i}]`
+    if (!SEVERITIES.includes(issue.severity)) errors.push(`${where} severity 非法:${issue.severity}`)
+    if (allowed && !allowed.includes(issue.category)) {
+      errors.push(`${where} category「${issue.category}」越界(${reviewType})`)
+    }
+    for (const f of REQUIRED_FIELDS) {
+      if (issue[f] == null || issue[f] === '') errors.push(`${where} 缺字段 ${f}`)
+    }
+    // 阻断规则
+    let blocking = !!issue.blocking
+    if (issue.severity === 'critical') blocking = true
+    if (issue.category === 'unregistered_thread') blocking = false
+    return { ...issue, blocking }
+  })
+
+  const blocking_count = normalized.filter((x) => x.blocking).length
+  const out = {
+    ...report,
+    issues: normalized,
+    issues_count: normalized.length,
+    blocking_count,
+    has_blocking: blocking_count > 0,
+  }
+  return { ok: errors.length === 0, report: out, errors }
+}

+ 64 - 0
v7/test/dto/character-context.test.js

@@ -0,0 +1,64 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { assembleCharacterContext } from '../../src/dto/character-context.js'
+import { makeGitBook } from '../state-machine/_helper.js'
+
+const charCard = `---
+姓名: 林晚
+别名:
+  - 晚晚
+状态: 在世
+位置: 青云宗
+境界: 练气三层
+持有:
+  - 青霜剑
+最后变更章: 1
+---
+## 设定
+外门弟子,心性坚韧。`
+
+test('assembleCharacterContext:从角色卡 front matter 组装 DTO', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n',
+    '定稿/设定/角色/林晚.md': charCard,
+  })
+  try {
+    const r = await assembleCharacterContext(ctx, '林晚')
+    assert.equal(r.ok, true)
+    assert.equal(r.context.正名, '林晚')
+    assert.equal(r.context.境界, '练气三层')
+    assert.equal(r.context.状态, '在世')
+    assert.equal(r.context.位置, '青云宗')
+    assert.deepEqual(r.context.别名, ['晚晚'])
+    assert.deepEqual(r.context.持有, ['青霜剑'])
+  } finally {
+    await cleanup()
+  }
+})
+
+test('assembleCharacterContext:DTO 不泄漏文件路径(不变量:AI 永不见文件结构)', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n',
+    '定稿/设定/角色/林晚.md': charCard,
+  })
+  try {
+    const r = await assembleCharacterContext(ctx, '林晚')
+    const json = JSON.stringify(r.context)
+    assert.ok(!json.includes('定稿'), 'DTO 不应含定稿路径')
+    assert.ok(!json.includes('.md'), 'DTO 不应含文件名')
+    assert.ok(!json.includes(ctx.repoPath), 'DTO 不应含仓库绝对路径')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('assembleCharacterContext:角色不存在 → ok=false 不抛', async () => {
+  const { ctx, cleanup } = await makeGitBook({ 'book.yaml': '书名: 测\n' })
+  try {
+    const r = await assembleCharacterContext(ctx, '查无此人')
+    assert.equal(r.ok, false)
+    assert.equal(r.context, null)
+  } finally {
+    await cleanup()
+  }
+})

+ 106 - 0
v7/test/review/orchestration.test.js

@@ -0,0 +1,106 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { assembleReviewInput, mergeReviews, runReviews } from '../../src/review/index.js'
+import { makeGitBook, chapter } from '../state-machine/_helper.js'
+
+const charCard = (name, 境界) => `---\n姓名: ${name}\n状态: 在世\n位置: 青云宗\n境界: ${境界}\n---\n## 设定\n。`
+
+async function makeReviewBook() {
+  return makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n',
+    '定稿/正文/0001-起.md': chapter(1, '过去的事。'),
+    '定稿/设定/角色/林晚.md': charCard('林晚', '练气三层'),
+    '定稿/设定/时间线/第01卷.md': '| 章 | 一句话事件 |\n| --- | --- |\n| 1 | 林晚得玉佩 |\n',
+    '工作区/细纲.md': '## 本章要写到的事\n林晚突破练气四层。\n',
+    '工作区/草稿.md': '林晚运转功法,突破到练气四层。她握紧青霜剑。',
+  })
+}
+
+const fcIssue = (over = {}) => ({
+  severity: 'high', category: 'setting', location: '第1段',
+  description: '境界矛盾', evidence: '正文 vs 角色卡', fix_hint: '改回', blocking: false, ...over,
+})
+const edIssue = (over = {}) => ({
+  severity: 'low', category: 'pacing', location: '全章',
+  description: '节奏平', evidence: '无爽点', fix_hint: '加钩子', blocking: false, ...over,
+})
+
+test('assembleReviewInput:DTO 含草稿+要写到的事+相关角色,不泄漏路径', async () => {
+  const { ctx, cleanup } = await makeReviewBook()
+  try {
+    const r = await assembleReviewInput(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md' })
+    assert.equal(r.ok, true)
+    assert.match(r.input.草稿全文, /突破到练气四层/)
+    assert.match(r.input.本章要写到的事, /练气四层/)
+    assert.ok(r.input.相关角色.some((c) => c.正名 === '林晚'))
+    const json = JSON.stringify(r.input)
+    assert.ok(!json.includes(ctx.repoPath), '不泄漏仓库绝对路径')
+    assert.ok(!json.includes('定稿/设定'), '不泄漏内部目录路径')
+  } finally { await cleanup() }
+})
+
+test('mergeReviews:降级模式含兼容声明', () => {
+  const m = mergeReviews(
+    { factCheck: { issues: [] }, editorial: { issues: [] } },
+    { mode: 'degraded', chapterNum: 2 }
+  )
+  assert.match(m.模式声明, /兼容模式/)
+  assert.match(m.模式声明, /隔离度/)
+})
+
+test('mergeReviews:完整模式 + 合并计数', () => {
+  const m = mergeReviews(
+    {
+      factCheck: { issues: [fcIssue({ severity: 'critical', blocking: true })] },
+      editorial: { issues: [edIssue()] },
+    },
+    { mode: 'complete', chapterNum: 2 }
+  )
+  assert.equal(m.issues_count, 2)
+  assert.equal(m.blocking_count, 1)
+  assert.equal(m.has_blocking, true)
+  assert.match(m.模式声明, /完整/)
+})
+
+test('runReviews:DI 注入两审 → 校验+合并+落盘审稿单与评审报告', async () => {
+  const { ctx, cleanup, root } = await makeReviewBook()
+  try {
+    const reviewers = {
+      factCheck: async (input) => ({ chapter: input.章号, issues: [fcIssue()] }),
+      editorial: async (input) => ({ chapter: input.章号, issues: [edIssue()] }),
+    }
+    const r = await runReviews(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md', mode: 'complete', reviewers })
+    assert.equal(r.ok, true)
+    const 审稿 = await fs.readFile(path.join(root, '工作区', '审稿.md'), 'utf8')
+    assert.match(审稿, /突破到练气四层/, '审稿单含草稿')
+    assert.match(审稿, /setting/)
+    const factJson = await fs.readFile(path.join(root, '工作区', '评审报告', '事实审查.json'), 'utf8')
+    assert.match(factJson, /setting/)
+  } finally { await cleanup() }
+})
+
+test('runReviews:降级模式 → 审稿单含兼容声明', async () => {
+  const { ctx, cleanup, root } = await makeReviewBook()
+  try {
+    const reviewers = { factCheck: async () => ({ issues: [] }), editorial: async () => ({ issues: [] }) }
+    const r = await runReviews(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md', mode: 'degraded', reviewers })
+    assert.equal(r.ok, true)
+    const 审稿 = await fs.readFile(path.join(root, '工作区', '审稿.md'), 'utf8')
+    assert.match(审稿, /兼容模式/)
+  } finally { await cleanup() }
+})
+
+test('runReviews:审稿单越界 category → ok=false 带错', async () => {
+  const { ctx, cleanup } = await makeReviewBook()
+  try {
+    const reviewers = {
+      factCheck: async () => ({ issues: [fcIssue({ category: 'pacing' })] }), // pacing 不属事实审查
+      editorial: async () => ({ issues: [] }),
+    }
+    const r = await runReviews(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md', mode: 'complete', reviewers })
+    assert.equal(r.ok, false)
+    assert.ok(r.errors.length > 0)
+  } finally { await cleanup() }
+})

+ 68 - 0
v7/test/review/schema.test.js

@@ -0,0 +1,68 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { validateReviewReport } from '../../src/review/schema.js'
+
+const issue = (over = {}) => ({
+  severity: 'high',
+  category: 'setting',
+  location: '第3段',
+  description: '境界矛盾',
+  evidence: '正文"金丹" vs 角色卡"练气"',
+  fix_hint: '改回练气',
+  blocking: false,
+  ...over,
+})
+
+test('合法事实审查报告:通过 + 计数复算', async () => {
+  const r = validateReviewReport({ chapter: 5, issues: [issue(), issue({ category: 'timeline' })], issues_count: 99 }, { reviewType: 'factCheck' })
+  assert.equal(r.ok, true)
+  assert.equal(r.errors.length, 0)
+  assert.equal(r.report.issues_count, 2, '计数应被复算覆盖伪造的 99')
+})
+
+test('critical → 强制 blocking=true(即便 AI 报 false)', async () => {
+  const r = validateReviewReport({ chapter: 5, issues: [issue({ severity: 'critical', blocking: false })] }, { reviewType: 'factCheck' })
+  assert.equal(r.report.issues[0].blocking, true)
+  assert.equal(r.report.has_blocking, true)
+})
+
+test('unregistered_thread(D3)→ 强制 blocking=false(即便 AI 报 true)', async () => {
+  const r = validateReviewReport(
+    { chapter: 5, issues: [issue({ category: 'unregistered_thread', severity: 'high', blocking: true })] },
+    { reviewType: 'factCheck' }
+  )
+  assert.equal(r.report.issues[0].blocking, false, 'D3 即兴伏笔候选恒非阻断')
+  assert.equal(r.report.blocking_count, 0)
+})
+
+test('category 分域:编辑审 category 出现在事实审查 → 报错', async () => {
+  const r = validateReviewReport({ chapter: 5, issues: [issue({ category: 'pacing' })] }, { reviewType: 'factCheck' })
+  assert.equal(r.ok, false)
+  assert.ok(r.errors.some((e) => e.includes('越界')))
+})
+
+test('category 分域:事实审查 category 出现在编辑审 → 报错', async () => {
+  const r = validateReviewReport({ chapter: 5, issues: [issue({ category: 'setting' })] }, { reviewType: 'editorial' })
+  assert.equal(r.ok, false)
+})
+
+test('编辑审合法 category 通过', async () => {
+  const r = validateReviewReport({ chapter: 5, issues: [issue({ category: 'structure' })] }, { reviewType: 'editorial' })
+  assert.equal(r.ok, true)
+})
+
+test('缺字段 / 非法 severity → 报错', async () => {
+  const r = validateReviewReport(
+    { chapter: 5, issues: [{ severity: 'fatal', category: 'setting', description: '' }] },
+    { reviewType: 'factCheck' }
+  )
+  assert.equal(r.ok, false)
+  assert.ok(r.errors.some((e) => e.includes('severity')))
+  assert.ok(r.errors.some((e) => e.includes('evidence') || e.includes('location') || e.includes('fix_hint')))
+})
+
+test('issues 非数组 → ok=false 不抛', async () => {
+  const r = validateReviewReport({ chapter: 5 }, { reviewType: 'factCheck' })
+  assert.equal(r.ok, false)
+  assert.equal(r.report, null)
+})