فهرست منبع

feat: 完成 v7 全局评审整改(口径收口 + 基线/重试策略落地)

- 文档单一口径:批次周期、六表/organization、批次章状态四值枚举、决策 58-61;story-repo-spec 0.18 勘误(整份草稿 hash)、multi-agent spec v3.11、PRD 1.7
- CLI 用 pathToFileURL 构造命令模块 URL,含 #/%/空格/中文路径的真实子进程回归
- GPL v3 落实:v7/LICENSE 与根 LICENSE 等值,SPDX/版本三处一致,npm 白名单含三处 story-repo-spec/docs/architecture/v6 负向断言
- 文体基线:完整配置区间才建立、消费者只读精确起止、语料读取失败不连删基线、建书写盘前置校验、默认区间单源导出
- 重试预算:工作区/重试预算.json 按章持久化;机检自动修复两轮(整份草稿 hash 幂等)、两审初审+一次自动重审;review-input/runReviews 锁内预约、save-review 记账、定稿/丢批精确清理、预算文件进机器状态保护清单;--author-confirmed/--author-approved 单独记账不恢复额度
- SKILL 机检按返回 action 协议、两审预算口径修订,四宿主壳重生成
- 质量门:665/665 测试、格式 10/10、e2e:install、宿主壳 --check、npm pack 435 文件白名单全绿;契约守卫与完整 ReviewInput 令牌绑定回归保持通过
lingfengQAQ 2 ماه پیش
والد
کامیت
9e3b370d45
70فایلهای تغییر یافته به همراه3257 افزوده شده و 161 حذف شده
  1. 2 2
      .trellis/spec/backend/database-guidelines.md
  2. 7 4
      .trellis/spec/backend/directory-structure.md
  3. 1 1
      .trellis/spec/backend/error-handling.md
  4. 2 2
      .trellis/spec/backend/index.md
  5. 1 1
      .trellis/spec/backend/knowledge-runtime.md
  6. 54 2
      .trellis/spec/backend/quality-guidelines.md
  7. 16 0
      .trellis/spec/guides/cross-layer-thinking-guide.md
  8. 1 0
      .trellis/spec/guides/index.md
  9. 6 0
      .trellis/tasks/07-23-v7-global-review-remediation/check.jsonl
  10. 71 0
      .trellis/tasks/07-23-v7-global-review-remediation/design.md
  11. 8 0
      .trellis/tasks/07-23-v7-global-review-remediation/implement.jsonl
  12. 54 0
      .trellis/tasks/07-23-v7-global-review-remediation/implement.md
  13. 67 0
      .trellis/tasks/07-23-v7-global-review-remediation/prd.md
  14. 34 0
      .trellis/tasks/07-23-v7-global-review-remediation/research/CLI特殊路径根因分析.md
  15. 19 0
      .trellis/tasks/07-23-v7-global-review-remediation/research/后置债务清单.md
  16. 55 0
      .trellis/tasks/07-23-v7-global-review-remediation/research/评审核验基线.md
  17. 35 0
      .trellis/tasks/07-23-v7-global-review-remediation/research/质量门记录.md
  18. 26 0
      .trellis/tasks/07-23-v7-global-review-remediation/task.json
  19. 7 6
      docs/architecture/cache-design-2026-06-26.md
  20. 12 9
      docs/architecture/multi-agent-adaptation-spec-2026-06-05.md
  21. 22 11
      docs/architecture/story-repo-spec-2026-06-10.md
  22. 1 1
      docs/architecture/story-repo-spec-feedback-2026-06-11.md
  23. 1 1
      docs/architecture/v7-design-discussion-notes-2026-06-11.md
  24. 3 2
      docs/architecture/v7-implementation-plan.md
  25. 18 10
      docs/architecture/v7-prd.md
  26. 674 0
      v7/LICENSE
  27. 1 1
      v7/adapters/codex/support.md
  28. 4 4
      v7/bin/webnovel-writer.js
  29. 2 0
      v7/docs/migration-guide.md
  30. 3 2
      v7/package-lock.json
  31. 4 1
      v7/package.json
  32. 76 2
      v7/scripts/pack-install-e2e.mjs
  33. 6 4
      v7/skills/webnovel-writer/SKILL.md
  34. 1 1
      v7/src/cache/index.js
  35. 3 1
      v7/src/commands/batch-discard.js
  36. 91 6
      v7/src/commands/mechanical-check.js
  37. 37 6
      v7/src/commands/report-style-drift.js
  38. 24 4
      v7/src/commands/review-input.js
  39. 1 0
      v7/src/commands/save-review.js
  40. 3 2
      v7/src/commands/session-context.js
  41. 15 0
      v7/src/finalize/index.js
  42. 42 0
      v7/src/health-check/baseline.js
  43. 36 15
      v7/src/health-check/index.js
  44. 5 1
      v7/src/installer/index.js
  45. 8 4
      v7/src/installer/shells.js
  46. 14 0
      v7/src/knowledge/contract.js
  47. 4 11
      v7/src/mechanical-check/index.js
  48. 580 0
      v7/src/retry-policy/index.js
  49. 37 4
      v7/src/review/index.js
  50. 4 3
      v7/src/session/index.js
  51. 2 0
      v7/src/staging/contract-invalidation.js
  52. 18 13
      v7/src/staging/index.js
  53. 31 2
      v7/src/storage/parsers/book-config.js
  54. 1 1
      v7/test/commands/books-commands.test.js
  55. 128 0
      v7/test/commands/mechanical-check.test.js
  56. 63 4
      v7/test/commands/report-style-drift.test.js
  57. 40 1
      v7/test/finalize/finalize.test.js
  58. 140 4
      v7/test/health-check/index.test.js
  59. 25 2
      v7/test/host-shells/generate.test.js
  60. 53 0
      v7/test/installer/install.test.js
  61. 20 0
      v7/test/installer/units.test.js
  62. 32 1
      v7/test/integration/cli-spawn-smoke.test.js
  63. 52 2
      v7/test/knowledge/contract.test.js
  64. 25 2
      v7/test/mechanical-check/check.test.js
  65. 25 0
      v7/test/package-metadata.test.js
  66. 273 0
      v7/test/retry-policy/policy.test.js
  67. 64 1
      v7/test/review/orchestration.test.js
  68. 5 3
      v7/test/session/session.test.js
  69. 34 1
      v7/test/staging/index.test.js
  70. 28 0
      v7/test/storage/parsers/book-config.test.js

+ 2 - 2
.trellis/spec/backend/database-guidelines.md

@@ -1,6 +1,6 @@
 # 数据与存储规范
 
-> 版本:基线 1.0(2026-06-12);1.1(2026-07-07)补 §5.4 事务边界钉死(第三轮 review B-S)。依据:story-repo-spec 0.14 §11(缓存)、不变量 1/2、PRD §4-16(真源唯一)。
+> 版本:基线 1.2(2026-07-23,补固定文体基线区间与指纹按需重算边界);1.1(2026-07-07)补 §5.4 事务边界钉死。依据:story-repo-spec 0.18 §11(缓存)、不变量 1/2、PRD §4-16(真源唯一)。
 
 ---
 
@@ -20,7 +20,7 @@
 
 2.4 表名属机器域,用英文:`chapters`、`threads`、`secrets`、`entities`、`entity_aliases`、`fingerprints`。`entity_aliases` 是 `entities` 的别名索引表(alias → entity_id),供别名解析与唯一性校验。表设计必须支撑精准读取接口(PRD §3.6)的全部查询。
 
-2.5 **派生统计必须确定性可重算**(M5.5 起,`fingerprints` 行与 meta 统计 key 如 `imagery_top`):同一批源文件任何时候重算,结果必须逐字段一致——禁止时间戳、随机量、locale 相关排序(排序用码元序比较,禁止 `localeCompare`);对象序列化的键顺序必须由固定的构造顺序保证。"删缓存重算后指纹逐字段一致"是测试断言项。写入方约定:基线段与近段章段重合时只落基线行(同主键两次 upsert 会翻转 `is_baseline`,见 cache-design §1.5)。
+2.5 **派生统计必须确定性可重算**(M5.5 起,`fingerprints` 行与 meta 统计 key 如 `imagery_top`):同一批源文件任何时候重算,结果必须逐字段一致——禁止时间戳、随机量、locale 相关排序(排序用码元序比较,禁止 `localeCompare`);对象序列化的键顺序必须由固定的构造顺序保证。"删缓存重算后指纹逐字段一致"是测试断言项。文体基线只在配置闭区间每章齐全时建立,所有消费者按当前配置精确起止读取;禁止部分前缀和“最大历史范围”回退。重建器清空 `fingerprints`,下一次体检按需重算;基线段与近段重合时只落基线行。
 
 ## 3. 禁止事项
 

+ 7 - 4
.trellis/spec/backend/directory-structure.md

@@ -1,6 +1,6 @@
 # 目录结构规范
 
-> 版本:基线 1.2(2026-07-18,十维知识运行时与治理文档分发生效)。依据:v7 PRD 1.6、story-repo-spec 0.17。
+> 版本:基线 1.4(2026-07-23,封闭 story-repo-spec npm 分发边界;1.3 同日补 GPL v3 许可证边界)。依据:v7 PRD 1.7、story-repo-spec 0.18。
 
 ---
 
@@ -43,8 +43,9 @@ webnovel-writer/                # 仓库根
 
 ```
 v7/
+├── LICENSE                 # 根 LICENSE 的字节等值 GPL v3 副本,进入 npm tarball
 ├── package.json            # name=webnovel-writer, type=module, engines.node>=22.13.0
-│                           # files 白名单:bin/ src/ roles/ skills/ adapters/ templates/(npm 包=安装源)
+│                           # files 白名单:LICENSE + bin/src/roles/references/skills/adapters/templates + 精确 docs 子项
 ├── bin/
 │   └── webnovel-writer.js  # CLI 入口:版本门槛先行 + 工作目录定位 + 子命令分发
 ├── src/
@@ -68,8 +69,10 @@ v7/
 
 4.2 模块为按职责(Use Case)划分,**非通用工具层**;端口拆小,禁止上帝对象(spec §1.5)。
 
-4.3 十维知识真源存放于 `v7/references/`,治理真源存放于 `v7/docs/knowledge/`;两者都必须进入 npm `files` 和 vendored `.webnovel/`。`references/README.md` 指向的三份治理文档必须在安装产物中存在,具体命令与测试契约见 [知识运行时](./knowledge-runtime.md)。
+4.3 十维知识真源存放于 `v7/references/`,治理真源存放于 `v7/docs/knowledge/`;两者都必须进入 npm `files` 和 vendored `.webnovel/`。npm 文档白名单只允许 `docs/knowledge/` 与 `docs/migration-guide.md`,禁止宽泛 `docs/`。`story-repo-spec` 的完整稿、精简稿或重命名副本均属开发仓库法律文本,不得进入 tarball 或 `.webnovel/`。`references/README.md` 指向的三份治理文档必须在安装产物中存在,具体命令与测试契约见 [知识运行时](./knowledge-runtime.md)。
 
 4.4 命令分级协议(M5):命令模块可选导出 `scope`——`'book'`(缺省,得 `{repoPath, cache}`)/ `'workdir'`(得 `{workdir, packageRoot}`,不建缓存)/ `'workdir-or-book'` / `'anywhere'`;空工作目录仍可跑的命令导出 `allowNoBook = true`(当前仅 `next`,状态机以 `repoPath=null` 判序 1 建书)。定位三分支见 `src/runtime/locate.js`:cwd 含 `book.yaml` 直启 → cwd 含 `.webnovel/` 按当前书解析 → 否则人话提示。
 
-4.5 工作目录 `.webnovel/` 为 vendored 运行时(自包含离线可跑):`bin/ src/ roles/ package.json + node_modules/<运行时依赖及传递依赖> + manifest.json(哈希清单)+ books.jsonl(用户数据,安装器只保底不覆盖)`。哈希清单键一律正斜杠(跨平台可移植)。
+4.5 工作目录 `.webnovel/` 为 vendored 运行时(自包含离线可跑):`bin/ src/ roles/ references/ docs/knowledge/ package.json + node_modules/<运行时依赖及传递依赖> + manifest.json(哈希清单)+ books.jsonl(用户数据,安装器只保底不覆盖)`。哈希清单键一律正斜杠(跨平台可移植)。
+
+4.6 v7 npm 包沿用根仓库 GPL v3:`package.json` 与 lockfile 根包 SPDX 均为 `GPL-3.0-only`,`v7/LICENSE` 必须与根 `LICENSE` 字节等值并进入 `files` 白名单。package/lockfile 三处版本必须一致;发布前从 `npm pack --dry-run --json` 的 tarball 清单验证许可证存在,且 v6 `webnovel-writer/`、`story-repo-spec` 和 `docs/architecture/` 均未混入,不能用工作树目录推断发布内容。

+ 1 - 1
.trellis/spec/backend/error-handling.md

@@ -10,7 +10,7 @@
 
 1.2 **宁停勿崩**:无法安全继续时必须停下并说明状态,禁止带着不一致状态继续执行。
 
-1.3 **可靠性来自自愈,不来自门禁**:能自动修复的先修复再报告;hook 语义必须是 ask,禁止 deny(v6 #113 教训)。
+1.3 **可靠性来自自愈,不来自门禁**:能自动修复的先修复再报告。7.0.0-alpha 不安装 PreToolUse,依靠 `next` 序 2 事后检测手改;未来若实现 hook,语义必须是 ask,禁止 deny(v6 #113 教训)。
 
 1.4 **系统适应作者,不报错拒绝**:作者手改源文件是合法操作。检测到未入账手改必须提议补登,禁止报错、禁止拒绝启动。
 

+ 2 - 2
.trellis/spec/backend/index.md

@@ -16,10 +16,10 @@
 
 | 规范 | 内容 | 状态 |
 |-------|-------------|--------|
-| [目录结构](./directory-structure.md) | 分支策略、仓库布局、v7 代码布局原则 | 基线 1.0 |
+| [目录结构](./directory-structure.md) | 分支策略、仓库布局、v7 代码布局原则 | 基线 1.3 |
 | [数据与存储](./database-guidelines.md) | 真源原则、`.cache/index.db`、防呆方言 | 基线 1.0 |
 | [错误处理](./error-handling.md) | 永不崩溃、错误分级、原子性、错误文案 | 基线 1.0 |
-| [质量规范](./quality-guidelines.md) | 零依赖、职责分界、CI 要求、评审清单 | 基线 1.0 |
+| [质量规范](./quality-guidelines.md) | 零依赖、职责分界、CI 要求、评审清单 | 基线 1.1 |
 | [输出与日志](./logging-guidelines.md) | 作者界面域/机器域分离、报告文风 | 基线 1.0 |
 | [知识运行时](./knowledge-runtime.md) | 十维查询、作品契约、计划对象、章级选择、事实转正与分发契约 | 基线 1.5 |
 

+ 1 - 1
.trellis/spec/backend/knowledge-runtime.md

@@ -1,6 +1,6 @@
 # 知识运行时开发规范
 
-> 版本:基线 1.5(2026-07-22)。产品边界以 PRD 1.6、story-repo-spec 0.17 和 `v7/docs/knowledge/` 为准。
+> 版本:基线 1.5(2026-07-22;2026-07-23 引用基线更新)。产品边界以 PRD 1.7、story-repo-spec 0.18 和 `v7/docs/knowledge/` 为准。
 
 ## 1. Scope / Trigger
 

+ 54 - 2
.trellis/spec/backend/quality-guidelines.md

@@ -1,6 +1,6 @@
 # 质量规范
 
-> 版本:基线 1.0(2026-06-12)。依据:PRD §1.4 产品原则 2/5、§5 非功能需求、§7 发布判据;story-repo-spec 0.6 §2.2。
+> 版本:基线 1.2(2026-07-23,补持久重试预算、alpha hook 边界与严格 npm 文档白名单)。依据:PRD 1.7 §1.4/§3.7/§5;story-repo-spec 0.18。
 
 ---
 
@@ -16,7 +16,7 @@
 
 2.1 **脚本能做的归脚本,做不到的归 AI 语义判断**:可计数项(字数、频次、格式校验、关键词命中)必须用脚本实现,禁止让模型估算;语义判断(是否真泄密、正文是否写到了某事)必须归 AI,**禁止用正则硬凑语义判断**。
 
-2.2 机检必须零 token;AI 调用每章预算:**完整两审 = 2 次**(事实审查 1 + 编辑审 1,各自独立上下文);**降级模式 = 1 次**(单上下文顺序审)。超预算由宿主壳/编排层拒绝,不在 `runReviews` 内硬凑。
+2.2 机检必须零 token;首次失败后最多自动修复两个不同草稿版本,同一草稿内容 hash(含章档案 front matter,行尾归一)重检幂等——只改 front matter 也是一次修复,必须消耗额度。两审的自动预算按轮计:**完整模式**初审 2 次调用(事实审查 1 + 编辑审 1)并最多整对重跑一次,自动上限 4 次;**降级模式**初审 1 个顺序审上下文并最多重跑一次,自动上限 2 个。`工作区/重试预算.json` 按章持久化,`review-input`/`runReviews` 必须在调用模型前预约额度;超限转作者,禁止只靠宿主提示软约束。作者用同一 ReviewInput 重提裁决不计重审;显式批准的额外轮次(`--author-approved`/`--author-confirmed`)单独记账,不恢复自动额度。
 
 2.3 **精准读取**:每类数据文件必须配"定位读到所需一段"的脚本接口;写作材料组装默认用片段,禁止默认整文件读取。
 
@@ -66,3 +66,55 @@
 6.5 版本号:`v7/package.json` 在 M5 发版前为预发版号(`7.0.0-alpha`);发版时升 `7.0.0` 并与 README 徽章、`.claude-plugin/marketplace.json`、`plugin.json`、`CHANGELOG.md` 一致——README 版本表是 `plugin-version.yml` CI 硬约束,发版必须同步。
 
 6.6 宿主通道 I/O(M5):AI 产物回流命令的 JSON 输入一律走 `--file`/`--payload` 文件路径,禁止 stdin(Windows 中文管道编码不可靠);小体量 DTO 输出走 stdout(`next --json`),含正文全文的大 JSON 落工作区文件(`review-input`)。相对路径相对书仓库根(无书时相对工作目录)解析。
+
+## 7. 场景:CLI 文件 URL 与 npm 发布可移植性
+
+### 7.1 Scope / Trigger
+
+- CLI 把本地命令模块路径交给动态 `import()` 时适用。
+- 修改 `v7/package.json`、lockfile、许可证或 npm `files` 白名单时适用。
+
+### 7.2 Signatures
+
+- 文件路径转模块 URL:`pathToFileURL(commandPath).href`,其中 `commandPath` 是 `path.join` 得到的绝对文件系统路径。
+- 版本契约:`package.json.version === package-lock.json.version === package-lock.json.packages[''].version`。
+- 许可证契约:包与 lockfile 根包的 SPDX 均为 `GPL-3.0-only`;`v7/LICENSE` 与根 `LICENSE` 字节等值。
+
+### 7.3 Contracts
+
+- 禁止手拼 `file:///` 或只替换反斜杠;`#`、`%`、空格和中文必须由标准 URL API 编码。
+- npm `files` 必须包含 `LICENSE`,文档项只允许 `docs/knowledge/` 与 `docs/migration-guide.md`,禁止宽泛 `docs/`;pack 产物必须包含许可证,不得包含 v6 `webnovel-writer/` 树、`story-repo-spec` 文件或 `docs/architecture/`。
+- 命令模块不存在时保持既有作者面契约:退出码 1、中文“未知命令”、不输出堆栈。
+
+### 7.4 Validation & Error Matrix
+
+| 条件 | 必须结果 |
+|---|---|
+| 包路径含 `#`/`%`/空格/中文,命令存在 | 动态导入成功,命令正常执行 |
+| 命令不存在 | 退出码 1,中文人话错误,stderr 无堆栈 |
+| 包/lockfile 版本或 SPDX 漂移 | 元数据测试失败,禁止发布 |
+| `v7/LICENSE` 缺失或与根文件不等值 | 元数据测试失败,禁止发布 |
+| tarball 缺许可证或混入 v6 树 | pack 发布门禁失败 |
+| tarball/安装目录出现 story-repo-spec 或 `docs/architecture/` | pack/install 发布门禁失败 |
+
+### 7.5 Good / Base / Bad Cases
+
+- Good:真实子进程从同时含四类特殊字符的包路径运行已知命令,并验证未知命令错误契约。
+- Base:普通 ASCII 路径行为不变。
+- Bad:用字符串拼出 `file:///`;只在 `package.json` 声明许可证却不验证随包文件;用仓库根文件清单代替 tarball 清单。
+
+### 7.6 Tests Required
+
+- `v7/test/integration/cli-spawn-smoke.test.js`:复制真实 `bin/src/node_modules` 到特殊路径,断言已知/未知命令。
+- `v7/test/package-metadata.test.js`:断言三处版本、两处 SPDX、精确 docs 白名单和许可证字节等值。
+- `npm pack --dry-run --json`:断言 tarball 含 `LICENSE`,不含 v6、story spec 或架构文档;`npm --prefix v7 run e2e:install` 对安装包与 `.webnovel/` 重复负向断言。
+
+### 7.7 Wrong vs Correct
+
+```js
+// Wrong: # 和 % 会被当作 URL 语义,Windows 盘符处理也不可靠。
+const commandUrl = new URL(`file:///${commandPath.replace(/\\/g, '/')}`).href
+
+// Correct: 把文件系统路径交给 Node 标准 API 编码。
+const commandUrl = pathToFileURL(commandPath).href
+```

+ 16 - 0
.trellis/spec/guides/cross-layer-thinking-guide.md

@@ -100,6 +100,22 @@ In Trellis, command templates (e.g., `record-session.md`) exist in **multiple pl
 
 ---
 
+## Filesystem Path to URL Boundary
+
+Node 文件系统路径和 URL 是两个协议层。只要本地路径进入动态 `import()`、Worker 或其他 URL 消费方,就先核对这条边界。
+
+### Checklist
+
+- [ ] 用 `pathToFileURL(absolutePath).href` 转换文件路径,禁止拼接 `file:///` 或手工替换路径分隔符
+- [ ] 反向转换使用 `fileURLToPath()`,禁止从 `url.pathname` 猜本地路径
+- [ ] 真实子进程测试同时覆盖 `#`、`%`、空格、中文和 Windows 盘符语义
+- [ ] 已知命令与不存在命令都走测试,确认错误仍是既有退出码和作者面人话
+- [ ] 发布边界从 `npm pack --dry-run --json` 的 tarball 清单验证,不用工作树目录推断包内容
+
+具体实现、错误矩阵和测试断言见后端 [质量规范 §7](../backend/quality-guidelines.md#7-场景cli-文件-url-与-npm-发布可移植性)。
+
+---
+
 ## Generated Runtime Template Upgrade Consistency
 
 Some generated files are both documentation and runtime input. In Trellis,

+ 1 - 0
.trellis/spec/guides/index.md

@@ -34,6 +34,7 @@ These guides help you **ask the right questions before coding**.
 - [ ] Data format changes between layers
 - [ ] Multiple consumers need the same data
 - [ ] You're not sure where to put some logic
+- [ ] A filesystem path crosses into a URL-based API or published package boundary
 
 → Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md)
 

+ 6 - 0
.trellis/tasks/07-23-v7-global-review-remediation/check.jsonl

@@ -0,0 +1,6 @@
+{"file":".trellis/spec/backend/index.md","reason":"按后端索引执行最终全范围质量检查"}
+{"file":".trellis/spec/backend/quality-guidelines.md","reason":"全量测试、生成器、安装 e2e 与特殊路径质量门"}
+{"file":".trellis/spec/backend/error-handling.md","reason":"确认 CLI 修复不破坏中文错误与失败契约"}
+{"file":".trellis/spec/backend/database-guidelines.md","reason":"确认基线/缓存行为与六表真源一致"}
+{"file":".trellis/spec/backend/knowledge-runtime.md","reason":"防止契约 guard、ReviewInput 令牌和批次状态回归"}
+{"file":".trellis/tasks/07-23-v7-global-review-remediation/research/评审核验基线.md","reason":"对照已核实问题、纠正项和明确不采纳的简化"}

+ 71 - 0
.trellis/tasks/07-23-v7-global-review-remediation/design.md

@@ -0,0 +1,71 @@
+# 技术设计:v7 全局评审整改
+
+## 1. 设计边界
+
+本任务横跨文档真源、CLI 入口、宿主壳生成、体检策略和发布元数据。行为变更遵守“先改 PRD/spec,再改代码”的项目规则;纯历史/措辞修订不扩大运行时行为。
+
+执行分为两条可并行轨道:确定性的文档/元数据/CLI/SKILL 序号修复不等待策略决策;基线、重试、hook 和 spec 分发只在各自决策完成后进入行为实现。GPL v3 已由 owner 确认,不再属于待决策略。
+
+安全闭环是不可变约束:
+
+`契约更新 → 全部未发布工件待重做 → 重做后原始定稿包 hash 证明 → commit confirmed 精确释放`
+
+以及:
+
+`完整 ReviewInput → 确定性令牌 → sidecar/两审/外层同令牌 → 锁内重组复核 → 暂存/定稿再次复核`
+
+评审提出的“只用版本号”或“有批次就禁止契约更新”会改变产品能力和安全保证,本任务不采用。
+
+## 2. 真源与同步矩阵
+
+| 主题 | 真源 | 派生/消费者 | 处理方式 |
+|---|---|---|---|
+| 批次周期 | PRD + story-repo-spec + `book-config` 默认值 | state-machine/staging/SKILL | 先统一术语和默认值,再跑状态机/批次测试 |
+| 缓存表与实体类型 | story-repo-spec + cache-design | cache schema/rebuilder/注释/实施计划 | 代码行为保持 `organization`/六表,清理误导性文字 |
+| 宿主提示 | `v7/skills`、`roles`、registry | `dist`、installer | 只改源,生成器重渲染,不手改 dist |
+| 文体基线 | owner 决策 + book.yaml/spec | health-check/staging/mechanical-check/tests | 完整配置区间才建基线;消费者只读精确区间 |
+| 重试/hook | owner 决策 + multi-agent spec | 重试预算脚本/SKILL/installer/状态机 | 每章持久计数;alpha 不安装 PreToolUse |
+| 许可证/版本 | 根 `LICENSE` 现行 GPL v3 + package metadata | README/npm pack/lockfile | GPL v3 已确认;不建立独立授权口径,`v7/LICENSE` 使用根许可证的等值副本随包分发 |
+
+## 3. 文档整改
+
+1. 搜索所有批次周期表述,不只改第 85 行;把手动 `体检周期` 与自动 `连写批次大小` 的关系写成“自动模式按批次体检”,不声称两个默认值相等。
+2. cache-design 的 DDL 注释、重建清单和校验清单与六表/`organization` 对齐;`v7/src/cache/index.js` 和现行实施计划中的旧“五表”文字要更新或显式加历史标签。
+3. story-repo-spec 如实记录同一个 `批次.json` 章状态枚举:`待审收/打回/受影响/契约变更`。污染传播只使用前三态;`契约变更` 由契约失效机制赋予(`v7/src/staging/index.js:70`),不是第二套平行状态机;同时更新决策 57 索引。
+4. 评审历史只作为证据,不在产品法律文本形成第二份当前答案;历史摘要可以保留,但必须标明版本和失效范围。
+
+## 4. CLI 与壳生成
+
+`v7/bin/webnovel-writer.js` 使用 `pathToFileURL(commandPath).href`,保留现有命令白名单、动态导入和中文错误处理。新增测试应通过真实子进程覆盖 `#`、`%`、空格、中文和不存在命令路径,确认编码与退出行为。
+
+SKILL 的序 4/5 展示顺序属于无条件修复,可以立即改源并生成所有宿主壳。重试说明、hook 声明和 spec 分发文字属于决策相关修复,只在对应选择确认后修改;若再次改动源文件,必须再次生成宿主壳。所有变更都只改源文件,随后检查 TOML/Markdown 角色和安装器输入。
+
+`build-host-shells --check` 只验证生成器确定性与 validator,不宣称它能比较被忽略的磁盘 dist;如要保留 dist 作为开发便利产物,另加显式 compare 脚本,不把它作为 npm 发布真源。
+
+## 5. 基线与重试设计
+
+### 基线
+
+- `文体基线起/止` 必须是正整数且起不大于止;配置的闭区间每章都存在才建立基线。
+- 不完整时体检明确报告“尚未建立基线”,不写部分前缀;机检、批次质检和漂移报告都静默跳过基线比较。
+- 所有消费者按当前配置的精确起止查询,不能用“最大的历史基线”。作者改区间时移除旧活动基线,完整后建立新基线。
+- 冻结的是章段而不是数据库快照;`fingerprints` 仍是可删派生物,缓存刷新后由下一次体检从同一章段确定性重算。
+
+### 重试
+
+- 机器记录固定为书仓库 `工作区/重试预算.json`,schema 版本化并按章号保存,跨宿主和会话共享;畸形记录 fail closed,不猜测剩余额度。
+- 机检首次失败只开启修复循环;之后每次重检代表消耗一轮自动修复,最多两轮。第三次仍失败时返回“自动修复已用完”,SKILL 必须停下交作者,不能继续自动改稿。
+- 每次 `review-input` 签发代表一轮两审:完整模式一轮两次 AI 调用,兼容模式一轮一个顺序审上下文。自动额度为初审一轮加重审一轮;第三轮自动签发必须拒绝。
+- 作者裁决冲突/歧义后,使用原报告和同一 ReviewInput 重新 `save-review` 不计新一轮。确需再次调用两审时必须使用显式作者批准参数;该轮单独记账且不恢复自动额度。
+- 章节提交确认后清该章记录;丢弃批次时清该批全部章。计数写入与 ReviewInput 签发共用作品状态锁,避免并发超发。
+
+### Hook 与包边界
+
+- alpha 不生成或安装 `PreToolUse`。`SessionStart` 只注入书单上下文;`next` 序 2 是事后自愈兜底。未来 hook 仍只能 ask,不能 deny。
+- npm `files` 不使用宽泛 `docs/`;只列 `docs/knowledge/` 与 `docs/migration-guide.md`。pack、install 和 init 后目录都做负向断言,禁止 `story-repo-spec` 或 `docs/architecture/` 进入产物。
+
+## 6. 发布与回滚
+
+- 发布前以 `npm pack --dry-run --json` 检查包白名单、版本、与根 `LICENSE` 等值的随包许可证文件和 docs;包内不应出现 v6 树。
+- 文档修改可单独回滚;CLI 修复必须和回归测试同批回滚;GPL v3 元数据与打包文件作为一批同步和回滚,禁止留下根仓库与 v7 授权不一致的中间状态。
+- 若基线/重试策略实现导致现有 635 条测试或契约 guard 回归,优先回退行为代码,保留决策与证据文档,重新规划。

+ 8 - 0
.trellis/tasks/07-23-v7-global-review-remediation/implement.jsonl

@@ -0,0 +1,8 @@
+{"file":".trellis/spec/backend/index.md","reason":"后端规范入口与文档先行要求"}
+{"file":".trellis/spec/backend/directory-structure.md","reason":"v7 包边界、生成壳真源和 npm 分发约束"}
+{"file":".trellis/spec/backend/database-guidelines.md","reason":"六表缓存、指纹基线与工作区暂存约束"}
+{"file":".trellis/spec/backend/error-handling.md","reason":"CLI 人话错误、宁停勿崩与 hook ask 语义"}
+{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Node/依赖、AI 调用预算、跨平台和质量门"}
+{"file":".trellis/spec/backend/knowledge-runtime.md","reason":"契约守卫与完整 ReviewInput 绑定不可回退的运行时契约"}
+{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"本整改横跨文档、CLI、生成器、安装器与发布元数据"}
+{"file":".trellis/tasks/07-23-v7-global-review-remediation/research/评审核验基线.md","reason":"两份评审删除后保留的事实裁定、证据和否决理由"}

+ 54 - 0
.trellis/tasks/07-23-v7-global-review-remediation/implement.md

@@ -0,0 +1,54 @@
+# 执行计划:v7 全局评审整改
+
+## Phase 1:决策与证据(与确定性修复并行;策略实现不得先行)
+
+- [x] 1.1 阅读 `research/评审核验基线.md`,确认当前 HEAD、评审裁定和删除边界。
+- [x] 1.2a 记录 owner 已确认的 GPL v3 选择。
+- [x] 1.2b 收敛基线策略、重试预算、PreToolUse 和 spec 分发四项选择;未决项只阻断各自的策略实现,不阻断下列确定性修复。
+- [x] 1.3 更新 PRD/spec 的已决行为条款,确保实现者不需要从评审原稿猜测语义。
+
+并行边界:2.1-2.4、3.1-3.3 都不依赖剩余四项策略决策,可以与 Phase 1 并行;Phase 4 以及 3.4 中对应的策略文字必须等待相关决策落定。
+
+## Phase 2:确定性文档与元数据修复
+
+- [x] 2.1 `[可并行]` 全量修订 PRD 批次口径(85/258/372 等所有命中点)。
+- [x] 2.2 `[可并行]` 修订 cache-design、cache 注释和现行实施计划的五表/`faction` 旧口径;历史材料加版本边界。
+- [x] 2.3 `[可并行]` 修订 story-repo-spec:`章状态` 的完整枚举包含 `待审收/打回/受影响/契约变更`;其中污染传播流程使用前三态,`契约变更` 是同一 `批次.json` 章状态枚举中的取值,由契约失效机制赋予(`v7/src/staging/index.js:70`),不是第二套平行状态机;同时核对决策 57 记录。
+- [x] 2.4 `[可并行,GPL v3 已决]` 以根 `LICENSE` 的现行 GPL v3 授权为真源,同步 README、PRD、v7/package.json 和 package-lock,并在 `v7/LICENSE` 放置根许可证的等值副本供 npm 包分发;不得建立独立授权口径,也不得扩大或缩小现有授权范围。
+
+## Phase 3:运行时与宿主链
+
+- [x] 3.1 `[可并行]` 用 `pathToFileURL` 修复 CLI 动态导入。
+- [x] 3.2 `[可并行]` 添加特殊字符路径真实子进程回归测试。
+- [x] 3.3 `[可并行,无条件]` 修正 SKILL 的序 4/5 展示顺序,重新生成所有宿主壳;禁止直接编辑 dist。
+- [x] 3.4 `[决策已完成]` 按已确认的基线/重试/hook/spec 分发决策更新 SKILL、multi-agent spec 和包边界;角色任务书不含这些编排策略,无需伪造改动。如源文件改变,再次生成所有宿主壳。
+- [x] 3.5 运行 host-shell tests、生成器 `--check` 和安装 e2e。
+
+## Phase 4:策略实现(各项在对应决策完成后分别执行)
+
+- [x] 4.1 按选定基线策略同步 health-check、staging、mechanical-check、配置默认值、报告和测试。
+- [x] 4.2 按选定重试策略实现计数/呈报/降级行为;分别覆盖机检、两审重跑和作者裁决重提。
+- [x] 4.3 按 hook 决定更新 installer/spec 或写明确的 deferred 声明;不得让关键能力只依赖 hook。
+
+## Phase 5:后置项转交
+
+- [x] 5.1 在 `research/后置债务清单.md` 登记 staging 拆分、SQL 去重、字数容差、650 章压测、语义检索 RFC 和仓库清理,且各有负责角色、触发条件和独立验收入口。
+- [x] 5.2 迁移文档只补 Dashboard/实体图谱尚未解释的能力回退,不重复改已存在的“迁了什么/没迁什么”。
+
+## Validation Gate
+
+- [x] `npm --prefix v7 test`:638/638
+- [x] `node --test v7/test/references/format.test.js`:10/10
+- [x] `npm --prefix v7 run e2e:install`
+- [x] `node v7/scripts/build-host-shells.mjs --check`
+- [x] 所有受影响 JS/MJS `node --check`:5/5
+- [x] `python ./.trellis/scripts/task.py validate .trellis/tasks/07-23-v7-global-review-remediation`
+- [x] `git diff --check`,另对新建任务文档做未跟踪文件空白检查
+- [x] 复核契约 guard、ReviewInput 令牌、批次状态洗回、提交未知和旧审稿回流测试未回归
+
+## Risk / Rollback Points
+
+- 文档真源改错:回退同一文档批次,不改运行时代码。
+- CLI URL 修复:必须与回归测试同批;失败时回退两者。
+- 基线或重试策略:先回退行为实现,再保留已批准决策,禁止通过放宽 guard 让测试变绿。
+- 许可证:GPL v3 已由 owner 确认;同步出错时整批回退相关元数据和打包变更,不留下根仓库与 v7 授权不一致的中间状态。

+ 67 - 0
.trellis/tasks/07-23-v7-global-review-remediation/prd.md

@@ -0,0 +1,67 @@
+# v7 全局评审整改
+
+## Goal
+
+把 2026-07-22/23 两份全局评审中已经核实的问题转成可执行、可验收的 v7 整改任务:先消除发布前的文档/运行时不一致,再把仍需产品决策的策略明确记录,避免用一次性“顺手修”改变已验收的安全不变量。
+
+本任务的基线是当前 `v7` 分支 HEAD `fd3979d`。评审原稿在本任务写入后删除;所有仍需保留的证据、否决理由和未决项见 `research/评审核验基线.md`。
+
+## Confirmed Scope
+
+### Release-blocking or deterministic fixes
+
+- 修正 PRD 中所有连写批次/体检周期的重复口径(至少 `v7-prd.md:85,258,372`),明确手动体检周期与自动连写批次大小不是同一个默认值。
+- 修正缓存设计真源及其引用:`entities.type` 使用 `organization`,缓存表为六表(含 `entity_aliases`);同步代码注释和仍被当作现行路线图的旧表述,历史文档要么更新要么明确标为历史。
+- 明确批次状态语义:`批次.json` 的章状态完整枚举包含 `待审收/打回/受影响/契约变更`;污染传播流程使用前三态,`契约变更` 由契约失效机制赋予,是同一枚举中的第四个取值,不是第二套平行状态机。
+- 更新 story-repo-spec 的决策记录计数至当前最高决策 57,并保留 57 的 ReviewInput 绑定证据。
+- 修正 CLI 入口使用 `pathToFileURL()` 构造命令模块 URL,并加入包含 `#`、`%`、空格和中文的路径回归测试。
+- 无条件修正 SKILL 的序 4/5 展示顺序;任何 SKILL/角色源文件修改都必须重新生成宿主壳并通过生成器确定性检查,源壳、安装器和测试不得出现第二套手写口径。
+- 项目所有者已确认 v7 沿用根仓库现行 GPL v3,不另行采用 MIT/Apache;以根 `LICENSE` 的现有授权范围为真源,统一 v7 `package.json`、README、PRD 和 npm 包元数据,并在 npm 包内放置根许可证的等值副本。
+- 同步 `v7/package-lock.json` 的包版本元数据与 `v7/package.json`,并在发布检查中验证 npm tarball 的文件白名单。
+
+### Resolved policy decisions
+
+- 新书文体基线:只有 `book.yaml` 配置的闭区间 `文体基线起..文体基线止` 每章都已定稿时才建立基线;未齐全时不写部分前缀基线,也不做文体漂移比较。配置区间固定后,后续章节不扩大基线;作者修改区间才切换。指纹仍是可删除、可按同一章段确定性重算的缓存派生物,不新增第二真源。
+- 机检/两审重试:每章写入 `工作区/重试预算.json` 并跨会话保留。首次机检失败后最多自动修复两轮;完整两审为事实审查+编辑审一对,初审后最多自动整对重跑一次。预算耗尽立即停止自动流程并交作者;作者裁决后用同一 ReviewInput 重提不计为重审,作者明确批准的新重审必须显式传参且不重置自动预算。章节成功定稿或整批丢弃后清理对应计数。
+- PreToolUse:`7.0.0-alpha` 明确不实现、不安装。Claude Code `SessionStart` 只注入当前书、书单和全书近况入口;所有宿主执行 `next` 时由状态机序 2 检测未登记手改并提议 `relink`。这是事后自愈,不冒充前置拦截;未来若实现仍只能 ask,且需另做宿主兼容性与安装测试。
+- story-repo-spec 分发:完整稿、精简稿和重命名副本均只留在开发仓库,完全不进入 npm tarball 或安装后的 `.webnovel/`。包内文档只允许 `docs/knowledge/**` 与 `docs/migration-guide.md`;作者诊断依靠 CLI、SKILL、错误信息和知识治理文档。
+
+### Accepted follow-up debt
+
+契约守卫复杂度、staging 拆分、统计 SQL 去重、字数容差配置化、650 章压测、语义检索插件 RFC、迁移文档补充 Dashboard/实体图谱替代路径、根仓库历史残留清理和 README/CHANGELOG v6 口径切换均登记为后置工作;不得以删除契约守卫或 ReviewInput 完整令牌绑定作为“简化”方案。
+
+## Requirements
+
+- **R1 文档单一口径**:同一概念在 PRD、story-repo-spec、cache-design、实施计划、代码注释、SKILL 和生成壳中的名称、默认值、状态集合一致;章状态文档必须说明污染传播三态与契约失效赋予的 `契约变更` 同属一个枚举;历史记录必须有明确历史标记。
+- **R2 运行时安全不回退**:保留契约失效守卫的待重做→原包 hash 待提交证明→commit confirmed 释放闭环,以及完整 ReviewInput 令牌/共享锁/sidecar 校验;本任务不得改成只校验版本号或只比较正文 hash。
+- **R3 跨平台 CLI 正确性**:特殊字符路径经 `pathToFileURL` 正确编码,命令加载失败仍返回现有中文人话错误契约。
+- **R4 生成链闭环**:无条件修正 SKILL 序 4/5;SKILL/角色源改动后,所有宿主壳由生成器产出,`--check`、宿主生成测试和安装 e2e 共同验证。
+- **R5 策略先决条件**:基线、重试、hook 和 spec 分发四项未决策略在对应实现前形成带 owner、选择、影响和验收方式的决策记录;未决策略不得阻断与其无关的确定性修复,也不得把“二选一”留在执行清单中。
+- **R6 发布元数据一致**:v7 沿用根仓库 GPL v3;包版本、lockfile、许可证声明、随包许可证文件和 npm 文件白名单互相一致,不把 v6 遗产误计入 v7 包。
+- **R7 后置项可追踪**:不在本次发布小批中重构 staging 或新增语义检索,但每项必须有负责人/触发条件/独立验收入口,避免评审发现丢失。
+
+## Acceptance Criteria
+
+- [x] `rg` 全量核对不再命中未标为历史的批次周期矛盾、`faction`、五表和错误状态枚举;PRD 的三个批次口径位置均已处理。
+- [x] story-repo-spec 决策记录最高编号为 57;章状态完整枚举为四值,文档明确污染传播使用前三态,`契约变更` 由契约失效机制在同一枚举中赋予。
+- [x] 特殊字符路径回归测试通过;CLI 入口不再手拼 `file:///`。
+- [x] SKILL 的序 4/5 展示顺序已无条件修正;SKILL 源与所有生成宿主壳逐字由生成器同步,`node v7/scripts/build-host-shells.mjs --check` 和宿主测试通过。
+- [x] 新书基线策略、重试预算、PreToolUse 取舍和 story-repo-spec 分发范围各有明确决策;对应 spec、SKILL、代码、包白名单和测试没有相互矛盾的旧口径。
+- [x] GPL v3 已落实到根文件、v7 元数据、README、PRD 和 npm pack 结果;`v7/LICENSE` 与根 `LICENSE` 等值,v7 没有 MIT/Apache 或其他相互矛盾的当前授权声明。
+- [x] `v7/package.json` 与 lockfile 版本一致,`npm pack --dry-run --json` 的文件集合符合白名单且不含 v6 树。
+- [x] 全量 `npm --prefix v7 test`、格式测试、安装 e2e、宿主壳检查、所有受影响 JS/MJS `node --check`、`task.py validate` 和 `git diff --check` 通过。
+- [x] 契约守卫、完整 ReviewInput 令牌绑定和已发布内容不可改语义的回归测试保持通过,未引入旧审稿结果回流或状态洗回。
+
+## Out of Scope
+
+- 不删除或弱化契约失效 guard、原包 SHA-256 证明、共享状态锁或完整 ReviewInput 令牌绑定。
+- 不在本任务内实现语义检索、PreToolUse hook(除非 owner 明确选择该方案并补充设计)。
+- 不将 staging 大模块拆分、迁移大规模压测和根仓库历史清理伪装成发布小批完成项。
+
+## Resolved Decisions
+
+1. **许可证(2026-07-23,owner)**:v7 沿用根仓库现行 GPL v3,不建立独立的 MIT/Apache 授权口径;实现时以根 `LICENSE` 的现有授权范围为准同步 SPDX/README/PRD,并让 `v7/LICENSE` 以等值副本随 npm 包分发,不得借元数据修复扩大或缩小授权。
+2. **文体基线(2026-07-23,owner)**:采用完整区间后冻结;冻结的是配置章段,缓存删除后允许从同一段确定性重算。未齐区间不建立部分基线,作者改区间才切换。
+3. **重试预算(2026-07-23,owner)**:机检最多自动修复两轮;两审最多初审一对加自动重审一对。按章持久化,超限转作者;显式作者批准可再发起一轮但不重置自动预算,定稿/丢批清理。
+4. **PreToolUse(2026-07-23,owner)**:alpha 延后实现;SessionStart 仅注入上下文,`next` 序 2 负责事后手改检测,二者均不宣称是写前 ask。
+5. **规范分发(2026-07-23,owner)**:`story-repo-spec` 及其任何副本完全不进 npm 包和 `.webnovel/`,不制作作者精简版。

+ 34 - 0
.trellis/tasks/07-23-v7-global-review-remediation/research/CLI特殊路径根因分析.md

@@ -0,0 +1,34 @@
+# Bug Analysis: CLI 特殊路径动态导入失败
+
+## 1. Root Cause Category
+
+- **Category**:E(隐式假设)+ D(测试覆盖缺口)。
+- **Specific Cause**:入口把 Windows/本地文件路径通过字符串替换拼成 `file:///` URL,隐含假设路径不含 URL 保留字符;`#` 会成为 fragment,`%` 会参与转义。原子进程测试只从普通临时路径运行,没有穿过真实“路径 → URL → 动态 import”边界。
+
+## 2. Why Fixes Failed
+
+此前没有修复尝试;问题一直未触发,是因为中文路径测试覆盖了文件 IO,却没有让**包自身路径**同时包含 `#`、`%` 和空格。只测工作目录中文不等于测命令模块 URL。
+
+## 3. Prevention Mechanisms
+
+| Priority | Mechanism | Specific Action | Status |
+|---|---|---|---|
+| P0 | Architecture | 统一用 Node `pathToFileURL(commandPath).href`,删除手拼 URL | DONE |
+| P0 | Test Coverage | 真实复制包到含四类特殊字符的路径,子进程执行已知与未知命令 | DONE |
+| P1 | Code Spec | 后端质量规范 §7 固化签名、错误矩阵、pack 边界和测试断言 | DONE |
+| P1 | Thinking Guide | 跨层 guide 增加文件路径与 URL 边界检查清单 | DONE |
+
+## 4. Systematic Expansion
+
+- **Similar Issues**:全仓运行时代码扫描未发现第二个手拼 `file:///` 的入口;现有 `fileURLToPath(import.meta.url)` 用法属于标准反向转换。
+- **Design Improvement**:这里无需再造 URL helper,Node 标准 API 已是单一正确抽象。
+- **Process Improvement**:跨平台测试必须区分“用户工作目录特殊字符”和“已安装包路径特殊字符”;发布内容必须检查 tarball,不检查工作树猜测结果。
+
+## 5. Knowledge Capture
+
+- [x] 更新 `.trellis/spec/backend/quality-guidelines.md`
+- [x] 更新 `.trellis/spec/backend/directory-structure.md` 与索引版本
+- [x] 更新 `.trellis/spec/guides/cross-layer-thinking-guide.md`
+- [x] 添加 CLI 特殊路径回归与包元数据测试
+- [x] 确认仓库没有 `src/templates/markdown/spec/` 镜像,因此无需同步模板
+- [ ] 提交与归档等待四项 owner 策略决策完成后统一执行

+ 19 - 0
.trellis/tasks/07-23-v7-global-review-remediation/research/后置债务清单.md

@@ -0,0 +1,19 @@
+# 后置债务清单
+
+本表只登记不属于本次发布小批的评审发现。「负责人」指责任角色,不表示已经授权扩大当前任务范围。触发条件到达后必须新建 Trellis 任务,不得在其他改动中顺手带过。
+
+| 项目 | 负责人 | 触发条件 | 独立验收入口 |
+|---|---|---|---|
+| staging/index.js 拆分 | v7 运行时维护者 | 下一个修改 staging 持久化、污染传播或批量定稿的功能任务启动前 | 专项任务定义模块边界;批次端到端、契约 guard、批内叠加与断电回滚测试全绿;批次 JSON 仍只有一个读写真源 |
+| 统计 SQL 去重 | 缓存/体检维护者 | 新增第二个复用现有统计口径的查询,或发现报告与状态机结果漂移 | 单一查询/谓词真源;边界数据对照测试通过;删缓存重建前后结果一致 |
+| 字数容差配置化 | 产品 owner + 机检维护者 | beta 真写证据表明单一阈值不适合目标题材,且 owner 确认新默认值/覆盖边界 | PRD 决策记录、book.yaml schema、阈值上下界测试和旧书默认行为回归 |
+| 650 章压测 | 发布/性能负责人 | 7.0.0 beta 出口前,或对外宣称 200 万字规模前,以较早者为准 | 可重复生成 650 章数据集;缓存重建、常用查询、next、体检和批次流程分项记录耗时/峰值内存;阈值由任务 PRD 预先确定 |
+| 语义检索插件 RFC | 架构 owner | 7.x 出现可复现的确定性检索召回缺口,并确认值得引入可选依赖 | RFC 证明插件可完全卸载;事实召回不依赖向量库;离线/索引损坏时主链仍可运行;提供精确率/召回率基线 |
+| 根仓库 v6 历史残留与 README/CHANGELOG 切换 | 发布负责人 | v7 成为默认发布线或准备合并到默认分支时 | 独立清理任务先列保留/归档/删除清单;README、CHANGELOG、安装入口指向同一主版本;npm 白名单与 v6 树隔离保持通过 |
+| book.yaml 解析失败时消费点静默回落默认值 | v7 运行时维护者 | 出现直接调用 mechanical-check/staging 命令绕过序 0 拿到默认配置的真实误判,或下一个改 book-config 消费点的任务启动前 | 评审裁定 `config.ok ? data : {}` 兜底点(mechanical-check/staging/book-status)是否改为显式报错;改动后主循环序 0 行为不回退,直调命令有明确中文报错测试 |
+| 基线/预算收尾测试缺口 | v7 运行时维护者 | 下一个触碰 health-check 序 0 联动或 state-machine 路由的任务启动前 | 补「book.yaml 基线字段非法 → next 序 0 修复确认」端到端回归;health-check 配置无效分支(config.ok=false 时报「文体基线配置无效」)可达性测试或删除死分支;mechanical-check「机检期间草稿变化不记账」分支(v7/src/commands/mechanical-check.js:67)补用例 |
+| 作品状态锁无失效自愈 | v7 运行时维护者 | beta 手测或真写中出现一次强杀进程后死锁,或下一个触碰锁机制的任务启动前 | `工作区/契约更新处理中.lock` 增加陈旧检测(pid 存活或年龄阈值),由 `next` 序 0 提议清理并保留作者确认;现有并发互斥与锁内不变量测试不回退 |
+
+2026-07-28 追加(全局评审整改收口时登记,来源见 research/评审核验基线.md 与两位核验代理报告):上表最后两行。
+
+Dashboard 和实体图谱的作者可见替代路径已在本任务回填 v7/docs/migration-guide.md,不再单独挂账。

+ 55 - 0
.trellis/tasks/07-23-v7-global-review-remediation/research/评审核验基线.md

@@ -0,0 +1,55 @@
+# 评审核验基线
+
+## 来源与删除边界
+
+- 来源文件:`v7-全局评审-2026-07-22.md`(Kimi)和 `v7-全局评审整改单-2026-07-23.md`(Fable)。
+- 当前基线:v7 分支 HEAD `fd3979d`,本任务创建时工作树只有上述两份未跟踪评审文件。
+- 任务文件写完并核验后,删除且只删除这两份根目录评审文件;`.workbuddy/`、代码、任务历史和其他未跟踪内容不得动。
+- 2026-07-23 owner 确认:两份评审文件均已按任务要求删除;`.workbuddy/` 由 owner 主动删除,不属于本任务范围,无需追踪或恢复。
+
+## 已坐实、进入整改
+
+| 主题 | 证据 | 处理结论 |
+|---|---|---|
+| PRD 批次口径 | `docs/architecture/v7-prd.md:85,258,372` 与 story spec `:200,203` | 全部命中点统一;不能只改第 85 行 |
+| 缓存旧口径 | `docs/architecture/cache-design-2026-06-26.md:114,308,371`;`v7/src/cache/index.js:29` | `organization` + 六表;代码注释和现行计划也要收口 |
+| 状态文字 | `story-repo-spec:570-571` 与 `v7/src/staging/index.js:66-70` | `契约变更` 是 `批次.json` 同一章状态枚举的取值,由契约失效机制赋予;污染传播只使用其余三态,不能写成两套平行状态机 |
+| CLI URL | `v7/bin/webnovel-writer.js:119` | `new URL(file:///)` 对 `#`/`%` 不安全;用 `pathToFileURL` 并加回归测试 |
+| 许可证/版本 | 根 LICENSE/README、`v7/package.json:3`、`v7/package-lock.json:3` | owner 已确认沿用根仓库 GPL v3、不拆分 v7 授权;版本和许可证元数据必须同步 |
+| 重试规范 | `story-repo-spec:517-518`、SKILL 曾写“重跑至过线” | owner 已明确机检两轮、两审初审+一次重审、按章持久化与超限转作者 |
+
+## 必须纠正的原评审裁定
+
+### 新书基线不是“全程跳过”
+
+`health-check/index.js:202-214` 在早期章数会写入 `1..maxChapter` 的部分基线;`staging/index.js:1415-1420` 每批定稿后调用体检;后续 `staging` 与 `mechanical-check` 读取最新基线。因此前几批会拿逐步扩大的早期自身基线比较。owner 已在 2026-07-23 选择“完整配置区间前不建立基线,完整后固定章段”;缓存仍可删后从同一章段重算。
+
+### 宿主壳 `--check` 不比较磁盘 dist
+
+`v7/src/host-shells/validator.js:85-92` 只把生成器跑两次并调用 validator;`v7/.gitignore:8` 忽略 `dist/`,安装器从源生成壳。当前磁盘 dist 可作为开发便利产物,但不是 npm 发布真源;若要检查磁盘一致性必须另写 compare,不应把现有命令当作证据。
+
+### 当前最高决策号是 57
+
+`docs/architecture/story-repo-spec-2026-06-10.md:704-705` 已包含决策 56、57;评审整改单中“56 决策”的统计是过时的。
+
+## 已确认的 owner 决策
+
+- 2026-07-23:v7 沿用根仓库现行 GPL v3,不采用评审历史中建议的 MIT/Apache,也不建立独立于根仓库的 v7 授权口径。实现时以根 `LICENSE` 的现有授权范围为准同步包元数据,并在 `v7/LICENSE` 放置根许可证的等值副本供 npm 包分发。
+- 2026-07-23:文体基线完整区间后固定;首次机检失败后最多自动修复两轮,两审最多初审一轮加自动重审一轮,计数按章跨会话持久化并在定稿/丢批后清理。
+- 2026-07-23:alpha 不实现 PreToolUse;SessionStart 只注入上下文,`next` 序 2 承担事后手改自愈,不能把两者描述成同一时机。
+- 2026-07-23:story-repo-spec 完整稿、精简稿和重命名副本均不进入 npm tarball 或 `.webnovel/`;包内文档只保留知识治理文档与迁移指南。
+
+## 不采纳的简化
+
+契约 guard 的复杂度和 staging 双向耦合登记为技术债,但不删除 guard、待提交原包 SHA-256、共享锁或完整 ReviewInput 令牌。版本号-only、禁止批内契约更新、只比正文 hash 都会改变已验收的 A22/A26 闭环。
+
+## 已有质量证据
+
+- 任务创建时 `npm --prefix v7 test`:635/635 通过;本轮确定性修复后最终质量门为 638/638,详见 `research/质量门记录.md`。
+- `node v7/scripts/build-host-shells.mjs --check`:生成器确定性与 validator 通过;这不等于磁盘 dist 比较。
+- 任务创建时 `npm pack --dry-run --json` 的基线发现 tarball 缺少许可证文件;本轮已补入等值 `v7/LICENSE`,最终复验为 433 个文件且包含 `LICENSE`,不含 v6 树,详见 `research/质量门记录.md`。
+- 迁移指南已经有“迁移了什么、没迁什么”(`v7/docs/migration-guide.md:34-46`);后续只补 Dashboard/实体图谱等替代路径。
+
+## 后置登记
+
+staging 拆分、统计 SQL 去重、字数容差配置化、650 章压测、语义检索接缝、根仓库 v6 残留和 README/CHANGELOG 切换不作为本任务的隐式“顺手修”,须在执行清单或独立任务中保留负责人和触发条件。“完整 spec 随包”已被 owner 明确否决,不再作为后置债务。

+ 35 - 0
.trellis/tasks/07-23-v7-global-review-remediation/research/质量门记录.md

@@ -0,0 +1,35 @@
+# 质量门记录
+
+首轮(确定性修复):2026-07-23,基线 HEAD fd3979d,638/638。
+终轮(策略实现与收口):2026-07-29,同一未提交工作树,含 4.1 基线策略、4.2 重试预算接线、3.4/4.3 SKILL 与 hook 边界及三轮核验(4.1 基线 / 4.2 重试 / 3.4+4.3 宿主链)+ 终审(A-H 全达标)后的全部修复。
+
+## 终轮结果
+
+| 质量门 | 结果 |
+|---|---|
+| npm --prefix v7 test | 665/665 通过,0 失败 |
+| node --test v7/test/references/format.test.js | 10/10 通过 |
+| 定向回归(staging/session/retry-policy/health-check/mechanical-check/package-metadata) | 通过;含契约 guard、批次状态洗回、提交未知边界、两审预约与清理、基线保留 |
+| npm --prefix v7 run e2e:install | pack → 安装 → 中文路径 init → 建书 → next → update 通过,含 story-repo-spec/docs/architecture 三处负向断言 |
+| node v7/scripts/build-host-shells.mjs(SKILL 修订后重生成) | 四个宿主壳生成成功 |
+| node v7/scripts/build-host-shells.mjs --check | 生成器确定性与 validator 通过 |
+| 受影响 JS/MJS node --check | 全部通过 |
+| task.py validate | implement.jsonl 8 条、check.jsonl 6 条,全部通过 |
+| git diff --check | 通过 |
+
+## 发布元数据(终轮复验)
+
+- npm pack --dry-run --json:webnovel-writer@7.0.0-alpha,435 个文件,包含 LICENSE,不包含 v6 webnovel-writer/ 树、story-repo-spec 或 docs/architecture/。
+- v7/package.json、lockfile 顶层与 lockfile 根包版本均为 7.0.0-alpha,SPDX 均为 GPL-3.0-only。
+- 根 LICENSE 与 v7/LICENSE SHA-256 均为 230184F60BAE2FEAF244F10A8BAC053C8FF33A183BCC365B4D8B876D2B7F4809。
+
+## 核验与修复轨迹(2026-07-28/29 收口)
+
+- 4.1 基线核验:A-F 六条达标;随修 4 项缺陷(体检语料失败不再连删基线、建书写盘前置校验基线区间、无基线指引分三种成因、切换齐全区间正例测试),基线默认区间由 book-config.js 单源导出。
+- 4.2 重试核验:机检侧达标;两审预约/作者批准入口/定稿与丢批清理原缺失,已全部接线并入原子批写入;`工作区/重试预算.json` 进机器状态保护清单;预算键改整份草稿 hash(含 front matter),已 saved 轮次的覆盖保存输出可见提醒。
+- 3.4+4.3 核验:达标;SKILL 机检步骤改按返回 action 协议、两审补预算口径,codex support.md、multi-agent spec(v3.11)、directory-structure §4.5 同步。
+- 终审:A-H 全达标,红线(契约守卫/原包 SHA-256/共享锁/完整 ReviewInput 令牌绑定)无弱化;新发现的锁自愈与测试缺口登记 `research/后置债务清单.md`。
+
+## 状态
+
+九条验收全部满足,任务可归档。

+ 26 - 0
.trellis/tasks/07-23-v7-global-review-remediation/task.json

@@ -0,0 +1,26 @@
+{
+  "id": "v7-global-review-remediation",
+  "name": "v7-global-review-remediation",
+  "title": "v7 全局评审整改",
+  "description": "修复 Kimi/Fable 全局评审核实的 v7 文档、CLI、宿主壳与发布元数据问题;确定性修复可与 owner 策略决策并行,基线/重试/hook 策略仅在决策后实现,并保留契约守卫与完整 ReviewInput 令牌闭环。",
+  "status": "in_progress",
+  "dev_type": null,
+  "scope": "v7",
+  "package": null,
+  "priority": "P1",
+  "creator": "codex",
+  "assignee": "codex",
+  "createdAt": "2026-07-23",
+  "completedAt": null,
+  "branch": null,
+  "base_branch": "v7",
+  "worktree_path": null,
+  "commit": null,
+  "pr_url": null,
+  "subtasks": [],
+  "children": [],
+  "parent": null,
+  "relatedFiles": [],
+  "notes": "",
+  "meta": {}
+}

+ 7 - 6
docs/architecture/cache-design-2026-06-26.md

@@ -1,6 +1,6 @@
 # `.cache/index.db` 设计与精准读取接口
 
-> 日期:2026-06-26(0.2 修订:2026-06-27,按 review 修派生数据物化策略)
+> 日期:2026-06-26(0.3 勘误:2026-07-23,对齐现行 `organization` 与含 `entity_aliases` 的六表;0.2 修订:2026-06-27,按 review 修派生数据物化策略)
 > 状态:设计文档(O4 消解)
 > 上游:`story-repo-spec-2026-06-10.md` 0.8、`v7-prd.md` 1.0
 > 目的:补全 spec §11 的表 DDL 和精准读取接口完整清单,作为 M1 实施的输入
@@ -111,7 +111,7 @@ CREATE INDEX idx_secrets_reader_knows ON secrets(reader_knows);
 ```sql
 CREATE TABLE entities (
   id TEXT PRIMARY KEY,                 -- 正名
-  type TEXT NOT NULL,                  -- character | location | item | faction
+  type TEXT NOT NULL,                  -- character | location | item | organization
   status TEXT,                         -- 在世|已死|失踪|封印...(角色专用)
   location TEXT,                       -- 最后已知位置(角色专用)
   realm TEXT,                          -- 境界(角色专用)
@@ -167,8 +167,9 @@ CREATE INDEX idx_fingerprints_baseline ON fingerprints(is_baseline);
 
 **说明**:
 - **指纹是确定性派生物**:同一章段(定稿不可改)任何时候重算都一样,所以身份只用 `(章段起, 章段止)`——**不带时间戳**。带 `computed_at` 会让"删 `.cache` 重建"对不上(时间戳无法复现),破坏不变量 2。
-- 何时算哪段由调用方决定(体检/卷复盘),算出来按章段 upsert;删缓存后这些行从定稿文本原样重算
-- 基线指纹由 `book.yaml` 的 `文体基线起/止` 决定
+- 何时算哪段由调用方决定(体检/卷复盘),算出来按章段 upsert;缓存重建先清空本表,下一次体检从定稿文本原样重算
+- 基线指纹由 `book.yaml` 的 `文体基线起/止` 决定;二者必须是正整数且起不大于止,闭区间每章都存在才写基线,禁止用 `起..当前最大章` 的部分前缀冒充
+- 基线消费者必须按当前配置精确查询起止;作者修改范围时旧活动基线删除,完整后才写新范围。后续新章不得扩大既有范围
 - 基线段与近段章段完全重合时(全书尚在基线区间内),调用方只落基线行——同主键两次 upsert 会让后写的一行翻转 `is_baseline` 标记(M5.5 体检的写入约定)
 
 ---
@@ -305,7 +306,7 @@ AI 角色与自动模式:
 
 ### 4.2 输出
 
-- 五表全量重建:删除旧数据 → 扫描源文件 → INSERT
+- 六表结构统一重建;`chapters`/`threads`/`secrets`/`entities`/`entity_aliases` 删除旧数据后扫描源文件并 INSERT,`fingerprints` 只清空,由后续体检按当前完整基线区间和近章窗口确定性重算
 - 触发时机:`.cache/index.db` 不存在或损坏时
 
 ### 4.3 校验
@@ -368,7 +369,7 @@ INSERT INTO secrets (...) VALUES (...);
 
 ## 7. 实施检查清单
 
-- [ ] 五表 DDL 在 SQLite 中可执行(语法检查)
+- [ ] 六表 DDL 在 SQLite 中可执行(语法检查)
 - [ ] 所有表都有主键和必要索引
 - [ ] 重建器逻辑明确:输入/输出/校验/边界
 - [ ] 精准读取接口清单完整(41 个接口)

+ 12 - 9
docs/architecture/multi-agent-adaptation-spec-2026-06-05.md

@@ -1,8 +1,8 @@
 # Webnovel Writer 多宿主与多智能体适配 Spec
 
-> 日期:2026-06-05(v3 修订:2026-06-11;v3.1:同日,hook 语义 deny → ask,依据 #113;v3.2:2026-06-12,按 PRD 1.0 §10.2 修订——安装器重写为工作目录布局、AGENTS.md 公约数层、SessionStart 注入、模板条件块、放弃插件市场;v3.3:2026-06-26,RFC 后续决策——两审模式、审稿清单定义;v3.4:2026-06-27,RFC 深度核验——事实审查增 D3 未登记伏笔检测 category;v3.5:2026-07-02,设计边界回顾——§6.1 目录图角色清单同步两审实态;v3.6:2026-07-03,M1-M5 review S-2——§7.1 registry 示例补 M5 实态字段 detect_bin/install_dir 等,照抄示例不再被 validator 打回;v3.7:2026-07-04,M6 自动模式——SKILL.md 单源增「自动模式(连写)」段并重渲染各宿主壳,§5.1 补批次暂存不开第二条写入路径;v3.8:2026-07-07,知识库重构(story-repo-spec 0.15)——§8.1 工作目录布局补知识库 references vendoring,SKILL.md 单源同步知识声明位与建书蒸馏并重渲染各宿主壳;v3.9:2026-07-18,story-repo-spec 0.17——宿主壳切换到作品契约、计划对象、四维章级选择与事实转正,安装器同时分发十维知识治理文档)
-> 状态:草案 v3.9
-> 基线:**v7 story repo**(`story-repo-spec-2026-06-10.md` 0.17)+ **PRD**(`v7-prd.md` 1.6,产品法律文本,冲突时以 PRD 为准)。v2 的基线是 v6.1.0 Python runtime,该架构已被 v7 推翻;本版继承 v2 的元层纪律,重写全部基座层。
+> 日期:2026-06-05(v3 修订:2026-06-11;v3.1-v3.9 历史见 git;v3.10:2026-07-23,发布审计明确重试预算、alpha 延后 PreToolUse,并封闭 story-repo-spec 包边界;v3.11:2026-07-28,重试段改按命令返回 action 协议表述,并补记账与 ReviewInput 共锁的并发提示说明)
+> 状态:草案 v3.11
+> 基线:**v7 story repo**(`story-repo-spec-2026-06-10.md` 0.18)+ **PRD**(`v7-prd.md` 1.7,产品法律文本,冲突时以 PRD 为准)。v2 的基线是 v6.1.0 Python runtime,该架构已被 v7 推翻;本版继承 v2 的元层纪律,重写全部基座层。
 > 来源:v2(基于 PR #110 review 重写)+ 2026-06 多平台调研核验 + Trellis 多宿主机制调研(2026-06-11)+ PRD 1.0 + RFC 后续决策(2026-06-26)+ RFC 深度核验(2026-06-27)
 > 定位:把 v7 的格式层(story repo)原封不动地暴露给多个 agent 宿主——格式平台无关,本 spec 只管入口怎么落、角色怎么生成、安装怎么零门槛、支持等级怎么诚实。
 
@@ -154,11 +154,13 @@ Claude Code 的使用体验是其他宿主的对照基准。**插件市场渠道
 
 **降级诚实条款(继承 v2)**:不允许声称调用了不存在的能力;机检与定稿是脚本,不参与降级。
 
-### 5.5 Hook 只是 Claude Code 上的自动兜底
+**每章重试预算**:机检与两审的自动循环有硬上限,宿主按命令返回的 `action`/预算字段行动(`revise-and-recheck` 继续、`hand-off-to-author`/`request-author-confirmation` 停下交作者),不得自行把修稿重检解释为无限循环。机检初次失败后最多自动修复两轮;两审最多自动执行初审一轮和整轮重审一次。完整模式每轮两次独立调用,兼容模式每轮一个顺序审上下文,但两者都只消耗一轮编排额度。脚本在 `工作区/重试预算.json` 按章、草稿 hash 和 ReviewInput 令牌持久记账;同输入恢复不重复扣。自动额度耗尽后宿主必须停下呈报作者,只有明确作者批准(`--author-confirmed`/`--author-approved`)才能再开一轮且不得重置自动额度。作者对同一报告完成事实裁决并重新 `save-review` 不属于重审。记账与 ReviewInput 签发共用作品状态锁;机检、审稿命令与暂存/定稿并发时会提示「已有作品状态写入正在处理」,稍后重试即可,属预期保护而非故障。
 
-- **SessionStart 注入**:读工作目录 `.webnovel/books.jsonl`,注入"当前在写哪本、共几本书、全书近况入口";再读当前书状态报全书近况。无 hook 宿主由状态机入口启动时读同一文件,行为等价。
-- **Hook 语义是 ask,不是 deny**:PreToolUse 检测到直写 `定稿/` 或 `大纲/伏笔|悬念|感情线/` 时提醒"这是定稿区,确认直写还是走写章流程?",确认后放行;事后由手改检测(story repo spec §9)提议补登兜底。deny 式 hook 无法区分"绕过定稿破坏一致性"和"替作者批量修数据",被拦的往往是后者——v6 的 state.json guard 即此教训(#113,已于 73447d7 放开)。这与 story repo spec 不变量 1("检测手改并提议补登,永不报错拒绝")和"可靠性来自自愈,不来自门禁"原则自洽。
-- 任何关键能力不得只存在于 hook 中;无 hook 宿主由状态机入口(每次启动先跑全书近况与手改检测)补足,行为等价。
+### 5.5 Alpha hook 边界与跨宿主自愈
+
+- **SessionStart 只注入上下文**:Claude Code 读取工作目录 `.webnovel/books.jsonl`,注入“当前在写哪本、共几本书、全书近况入口”,不读取或替作者总结全书近况。无 hook 宿主由生成的 SKILL 显式调用 `session-context` 得到同一信息。
+- **PreToolUse 明确延后**:`7.0.0-alpha` 不生成、不安装 PreToolUse,也不得声称直写前会弹 ask。所有宿主在执行 `next` 时,由状态机序 2 在进入后续创作态前检测定稿/大纲/文风的未登记手改并提议 `relink`;这是事后自愈,不是与写前 hook 时机等价的拦截。
+- **未来语义边界**:若以后实现 PreToolUse,只能 ask 后放行,不能 deny;并须单独核验宿主兼容性、全新安装、合并用户配置和无 hook 降级。关键正确性始终不得依赖 hook。
 
 ### 5.6 零依赖与 UTF-8 First(Node 口径)
 
@@ -272,7 +274,7 @@ node scripts/build-host-shells.mjs --check     # drift check,CI 必跑
 ### 8.1 init
 
 1. **检测环境**:Node ≥ 22(不足时人话提示升级);识别已安装的 agent CLI(按 registry 顺序探测)。
-2. **生成工作目录布局**(story repo spec §2.0):`AGENTS.md`(公约数层,标记块)、`.webnovel/`(Node 脚本、角色定义、十维知识 `references/`、治理真源 `docs/knowledge/`、模板哈希清单、`books.jsonl`)、检测到的各平台壳(`.claude/`、`.codex/` 等,由生成器按 §5.9 条件块编译)。
+2. **生成工作目录布局**(story repo spec §2.0):`AGENTS.md`(公约数层,标记块)、`.webnovel/`(Node 脚本、角色定义、十维知识 `references/`、治理真源 `docs/knowledge/`、模板哈希清单、`books.jsonl`)、检测到的各平台壳(`.claude/`、`.codex/` 等,由生成器按 §5.9 条件块编译)。`story-repo-spec` 的完整稿、精简稿和任何重命名副本都不进入 npm 包或 `.webnovel/`。
 3. **输出报告**:装到了哪、各宿主支持等级、降级说明、下一步(打开 agent CLI 说"开始写书")。
 
 ### 8.2 update
@@ -290,6 +292,7 @@ node scripts/build-host-shells.mjs --check     # drift check,CI 必跑
 
 - **drift check**:`build-host-shells.mjs --check`,验证生成器输出确定性(同输入必同输出),CI 必跑。
 - **package validator**:registry schema、逐宿主 support.md 存在性、smoke 命令声明、生成物无本机绝对路径、skill description 长度(Codex 8k 预算)。
+- **package boundary**:npm 文档只允许 `docs/knowledge/**` 与 `docs/migration-guide.md`;pack、install 和 init 后产物均须负向断言不存在 `story-repo-spec` 与 `docs/architecture/`。
 - **行为 smoke**(每个一级宿主):discover(skill 可发现)→ npx init → 建书 → 全书近况 → 写一章全流程(细纲→定稿)→ 删 `.cache/` 重建。Windows 中文路径全链路必测。
 - **降级验收**:至少一个无 subagent 环境跑通两审兼容模式,且输出含兼容模式声明。
 
@@ -328,7 +331,7 @@ node scripts/build-host-shells.mjs --check     # drift check,CI 必跑
 - [ ] update 哈希追踪:改过的文件不被静默覆盖。
 - [ ] registry 三级分级,一级宿主有 support.md + 过 smoke。
 - [ ] 无 subagent 宿主跑通两审兼容模式且如实声明。
-- [ ] hook 缺席的宿主核心流程行为等价(含 books.jsonl 上下文注入的等价路径)。
+- [ ] hook 缺席的宿主核心流程可用:生成壳显式调用 `session-context`,所有宿主的 `next` 序 2 均能发现未登记手改;不把事后自愈冒充 PreToolUse 写前拦截。
 
 ## 13. 简短结论
 

+ 22 - 11
docs/architecture/story-repo-spec-2026-06-10.md

@@ -1,6 +1,8 @@
-# Story Repo 格式规格(v7 现行 0.17)
+# Story Repo 格式规格(v7 现行 0.18)
 
-> 状态:0.17(2026-07-18 生效,2026-07-22 集成审计收紧来源追溯与未发布工件守卫)。相对 0.16 的变更(决策 45-57 见 §14):
+> 状态:0.18(2026-07-23 发布审计决策,决策 58-61 见 §14;2026-07-28 勘误:重检幂等键为含章档案 front matter 的整份草稿 hash,只改章档案的修复同样消耗额度):完整章段后建立文体基线;机检/两审预算按章持久化;alpha 延后 PreToolUse;本 spec 完全不随 npm 分发。
+>
+> 0.17 于 2026-07-18 生效,2026-07-22 集成审计收紧来源追溯与未发布工件守卫,2026-07-23 发布审计澄清 `批次.json` 章状态四值枚举与 GPL v3 口径。相对 0.16 的变更(决策 45-57 见 §14):
 > - 知识库采用十维唯一归属,治理真源随安装器分发在 `docs/knowledge/`;固定路由只归一书级名称,不选择创意答案。
 > - `作品契约/作品契约.md` 成为唯一书级真源;删除旧书级指导路径、关系结算枚举和题材默认值。
 > - 故事对象采用 `大纲/创作设计/` 计划对象到 `定稿/设定/` 事实对象的两阶段生命周期,事实转正与章节同一提交。
@@ -26,7 +28,7 @@
 >
 > 0.12 相对 0.11 的变更(2026-07-04 M6 自动模式落地,决策 36-37 见 §14):
 > - §3 book.yaml 增「连写无条目变动上限」键(0.6 决策 20 的"连续 3 章(可配)"至此有键,默认 3)
-> - §8.1 实现口径回填:批次目录三件套与批次.json 三态、污染传播机制(0.7 版留白"由 M6 实施时设计"至此收口)、暂存前置"审稿单必须已生成"、批次质检判据("体检不过线"的批次落点)
+> - §8.1 实现口径回填:批次目录三件套与污染传播三态、污染传播机制(0.7 版留白"由 M6 实施时设计"至此收口)、暂存前置"审稿单必须已生成"、批次质检判据("体检不过线"的批次落点);0.17 的契约失效机制在同一章状态枚举中增加 `契约变更`
 > - §8 第 8 步:定稿 commit 明确含条目文件**创建**(新开条目入档通道,M2 起潜伏缺口)
 >
 > 0.11 相对 0.10 的变更(2026-07-04 M5.5 体检落地,决策 35 见 §14):
@@ -97,7 +99,7 @@
 
 - **只支持从工作目录启动**:各宿主只在启动目录自动加载 skills/配置,在书目录启动插件不会加载。脚本启动时校验当前目录含 `.webnovel/`,否则人话提示"请从工作目录启动"。
 - **当前书与书单由 `books.jsonl` 解析**(机器域文件,作者不手编):换书 = 作者一句话让 AI 改登记;登记缺失/损坏 → 扫描含 `book.yaml` 的子目录重建书单,"当前在写"标记请作者选一次。
-- 宿主有 SessionStart hook 的(Claude Code),启动自动注入"当前在写哪本/共几本书/全书近况入口";无 hook 宿主由状态机入口读同一文件,行为等价。
+- 宿主有 SessionStart hook 的(Claude Code),启动自动注入“当前在写哪本/共几本书/全书近况入口”;无 hook 宿主由生成壳显式调用同一 `session-context`。所有宿主的 `next` 序 2 另做事后手改检测,不宣称具备 PreToolUse 写前拦截。
 - 安装、升级(模板哈希追踪)、平台壳生成由多宿主 spec §8 承接。
 
 ### 2.1 书仓库目录
@@ -521,6 +523,8 @@ B 方案:本章纯感情线过渡(定位改"过渡章"),伏笔-031 推
 
 **ReviewInput 绑定**:`review-input` 在作品状态共享锁内组装并写出 `工作区/审稿输入.json`,其中含正整数 `作品契约版本` 与 `审稿输入令牌`。令牌对排除根级令牌字段后的完整 DTO 做确定性 SHA-256:对象键递归按码元排序,数组顺序和字符串原文保留,因此章号、草稿正文与完整 front matter、契约版本/内容、细纲和批内上下文任一变化都会使令牌变化。事实审查、编辑审和 `save-review` 外层 JSON 必须逐字回传同一令牌,禁止重算;`save-review` 在同一共享锁内重新组装当前输入,核对 sidecar、自身令牌和三处回传后才原子写报告。暂存、重审收口与定稿再次核对当前 sidecar、审稿结果令牌、正文、完整章档案和契约;缺失、损坏、混用或漂移均整章零写入。AI 往返期间不长期持锁。
 
+**质量门重试预算**:机器状态统一写入 gitignored 的 `工作区/重试预算.json`,严格 schema、按章号保存、跨会话和宿主共享;损坏时 fail closed,不能静默重置。首次机检失败后最多自动修复两个不同草稿版本;同一草稿内容 hash(含章档案 front matter,行尾归一)重检幂等——只改 front matter 也是新草稿版本,消耗额度。第二个修订稿仍失败后,后续检查必须有作者明确确认,自动流程停止。每次 `review-input` 签发是一轮两审预约:完整模式一轮为两次独立调用,兼容模式一轮为一个顺序审上下文;自动额度仅初审一轮加重审一轮,第三轮在调用模型前拒绝,作者明确批准的额外轮次单独记账且不恢复自动额度。冲突/歧义由作者修改同一令牌的报告为“作者已裁决”后重提 `save-review`,不签发新 ReviewInput、不计新轮次。章节 commit confirmed 后清该章记录;批次丢弃后清该批记录,打回、restage、契约失效和提交失败/未知均保留。
+
 **审稿终止机制**(防止无限循环,RFC 决策 D2):
 
 - 两审输出结构化问题清单,每个问题包含:
@@ -565,10 +569,10 @@ ch(152): 北境的雪
 **全自动 ≠ 无控制,是控制上移到大纲层**:作者逐卷确认卷纲,一次确认管几十章。**自动模式与手动模式的差异只是确认粒度:逐章 vs 按批次**——状态机与八阶段零改动,没有"自动定稿"。
 
 - `自动确认细纲: true` → 第 2 步默认采纳细纲提案(提案必须含定位声明)。
-- **批次运行**:连写 `连写批次大小`(默认 8)章。每章走完 1-6 步后**不定稿**,暂存进 `工作区/待定稿/NNNN-标题/` 三件套:`草稿.md`(front matter+正文终稿,防呆方言序列化)、`定稿包.json`(完整定稿 payload,预登记的机器形态——**暂存的就是定稿时原样转正的数据,无第二套入档路径**)、`审稿单.md`(该章两审产物)。批次元数据 `待定稿/批次.json`(机器域 JSON):持久面只有章列表(各章 `{章号, 标题, 状态}`);起章与连续无条目变动计数是派生量——起章=章列表首项,计数由停止判定从暂存章 front matter 就地重算,不落盘防双源漂移(0.14 决策 39)。**暂存前置:该章审稿单必须已生成**——自动模式砍掉的是"逐章问作者",不是"逐章审";两审既是质量闸门也是批量审稿的呈报材料。
+- **批次运行**:连写 `连写批次大小`(默认 8)章。每章走完 1-6 步后**不定稿**,暂存进 `工作区/待定稿/NNNN-标题/` 三件套:`草稿.md`(front matter+正文终稿,防呆方言序列化)、`定稿包.json`(完整定稿 payload,预登记的机器形态——**暂存的就是定稿时原样转正的数据,无第二套入档路径**)、`审稿单.md`(该章两审产物)。批次元数据 `待定稿/批次.json`(机器域 JSON):持久面只有章列表(各章 `{章号, 标题, 状态}`),`状态` 的完整枚举为 `待审收`/`打回`/`受影响`/`契约变更`;污染传播只在前三态之间转换,`契约变更` 由契约失效机制赋予,不是第二套平行状态机。起章与连续无条目变动计数是派生量——起章=章列表首项,计数由停止判定从暂存章 front matter 就地重算,不落盘防双源漂移(0.14 决策 39)。**暂存前置:该章审稿单必须已生成**——自动模式砍掉的是"逐章问作者",不是"逐章审";两审既是质量闸门也是批量审稿的呈报材料。
 - **叠加视图与版本链**(规范性要求,RFC 决策 A1):写批内后续章时,备料/审稿输入/机检组装"`定稿/` + 待定稿批次预登记"的叠加视图——批内前章的设定变更、条目推进、时间线行在工作区就位,定稿时原样转正。**支持批次内依赖**:第 K+1 章可以使用第 K 章的预登记事实。staged 数据只存工作区文件,**不进缓存表、不进指纹与意象派生物**(缓存永远只派生自定稿,删缓存重建叠加输出不变)。
 - **契约更新守卫**:契约更新把所有当前未发布章原子放入 `契约失效.json` 的“待重做”集合,`打回` 章也不能跳过;作者可见 marker 与机器守卫必须语义一致。完成新细纲、备料、写稿和两审后,暂存只把该章转为“待提交证明”,证明绑定章号、契约版本、批次目录和 `定稿包.json` 原始字节 SHA-256,marker 与守卫仍保留。`stagedFacts` 仅接纳未失效章及 hash 验证通过的待提交章,删除/损坏 `批次.json` 或强改状态不能洗回旧事实。批量定稿重验 hash 后向单章定稿传内部授权,只在确认 commit 成功后精确释放该章;失败或结果未知保留证明。restage 原子换 hash,打回撤销相关证明,丢弃批次只清该批章且保留无关工作区章;任何 guard/marker/批次不一致或包篡改都 fail closed。
-- **污染传播**(批次状态机,三态 `待审收`/`打回`/`受影响`):第 K 章被作者打回 → K 置"打回"并清工件(重写后重新暂存覆盖),K+1..N 置"受影响"(工件保留,作者裁量**重审或重写**:重审=重跑两审后收回待审收,重写=重走写章流程后覆盖暂存)。批次中改稿或重审必须从 `batch-status --json` 读取该章现存 `草稿路径`,让 `review-input --draft=<草稿路径>` 与 `save-review --draft=<同一路径>` 审查同一份暂存草稿,再运行 `stage-chapter` 覆盖或 `batch-restage` 收口;禁止默认改审 `工作区/草稿-A.md`。**批量定稿入口硬校验:任一章状态非"待审收"即整批拒绝并人话列障碍**——"批内污染不出批次"由入口保证,不靠宿主自觉。
+- **污染传播**(同一章状态枚举中的三态子流程:`待审收`/`打回`/`受影响`):第 K 章被作者打回 → K 置"打回"并清工件(重写后重新暂存覆盖),K+1..N 置"受影响"(工件保留,作者裁量**重审或重写**:重审=重跑两审后收回待审收,重写=重走写章流程后覆盖暂存)。第四个枚举值 `契约变更` 只由上一条契约失效机制赋予,不参与本段污染传播转换。批次中改稿或重审必须从 `batch-status --json` 读取该章现存 `草稿路径`,让 `review-input --draft=<草稿路径>` 与 `save-review --draft=<同一路径>` 审查同一份暂存草稿,再运行 `stage-chapter` 覆盖或 `batch-restage` 收口;禁止默认改审 `工作区/草稿-A.md`。**批量定稿入口硬校验:任一章状态非"待审收"即整批拒绝并人话列障碍**——"批内污染不出批次"由入口保证,不靠宿主自觉。
 - **停止条件**(命中即停转人工,全部零 token 脚本判定,逐章暂存时判、早停少浪费):批次写满 / 批次质检不过线 / 卷纲耗尽或收卷声明(**绝不让模型裸奔编纲**;收卷后归卷复盘)/ 连续 `连写无条目变动上限`(默认 3)章无条目变动。**批次质检**即"体检不过线"的批次落点,判据:批尾连续弱钩 ≥ `连续弱钩上限`、任一 staged 章缺「书内时间」、staged 正文句式 vs 基线指纹超容差(口径同机检:平均句长 30%/句长方差 50%);例行体检(§9)仍只算定稿,在批量定稿成功后例行跑。停止判据(收卷/条目变动/书内时间)一律以暂存章 **front matter 声明**为准,宿主须保证定稿包 payload 与声明一致——两者分叉时以声明面裁定(0.14 决策 39)。
 - **批量审稿与定稿**:批次停止 → 呈报作者(各章状态、审稿单要点、停止原因、质检结论)→ 作者裁决(整批接受 / 改某几章重审 / 从某章起打回重写)→ 作者敲定后**逐章按序原子定稿**(每章独立 commit;中途失败停在该章,已入档保留——原子性按章保留;支持只转正前段"前几章先发")→ 下一批次。批内错误未定稿,直接改,影响最多波及一个批次。
 - **整批不要 → 丢弃批次**:批次是工作区文件(未入 git),丢弃=删除待定稿目录,定稿零变化,需作者明确确认;"回到第 N 章"(§9)管**已定稿**回退,两者职责不混。已发布后才发现的错误 → 影响分析 + 顺势圆(§9)。
@@ -578,7 +582,7 @@ ch(152): 北境的雪
 ## 9. 中环、外环与例外流程
 
 - **卷复盘** `vol(05): 复盘与下卷规划`:三类条目清账(本卷开/收/悬了太久清单)→ `摘要/卷摘要/` → 翻一遍灵感池 → 与作者对谈产出 `大纲/卷纲/第06卷.md` → 顺手做伏笔机会扫描(模型提 3-5 个"本卷可埋、N 卷后响"候选,必须引用总纲的具体远期节点,作者勾选后生成条目文件)。**执行体**:复盘持久化边界再次通过同一 `ContractReader` 校验作品契约,随后由脚本落盘并 commit(`vol(NN)` 前缀)+ 刷新缓存;契约缺失或损坏时零文件、零 commit。与定稿同为脚本责任,否则产物滞留未提交区误触序 2。
-- **体检**(手动模式每 50 章;连写中每批次一次):文体指纹 vs 基线区间的漂移报告 + 跨章高频意象与句式体检 + 条目活跃率与悬了太久清单 + 缺时间锚点(front matter 无「书内时间」,或时间线漏了该章的行)→ 报告进工作区,不入档;作者决定回拉或更新基线。高频意象清单同时进缓存,供备料「反复读清单」与机检候选(§8 第 5 步)消费——体检产出、机检消费,机检不做全书扫描。
+- **体检**(手动模式每 50 章;连写中每批次一次):文体指纹 vs 基线区间的漂移报告 + 跨章高频意象与句式体检 + 条目活跃率与悬了太久清单 + 缺时间锚点(front matter 无「书内时间」,或时间线漏了该章的行)→ 报告进工作区,不入档。`文体基线起..文体基线止` 是正整数闭区间;只有区间每章都已定稿时才建立基线,未齐全时明确报告并跳过比较,禁止写逐步扩大的前缀基线。建立后始终使用精确区间,作者改配置才切换;缓存删除或源刷新后可从同一固定章段确定性重算。高频意象清单同时进缓存,供备料「反复读清单」与机检候选(§8 第 5 步)消费——体检产出、机检消费,机检不做全书扫描。
 - **吃书**(作者界面叫吃书,commit 前缀仍 retcon):`retcon(87): 修正大长老境界设定`——显式流程,允许改定稿,要求 commit message 写明原因,设定/条目同步,留痕可查。
 - **影响分析**(脚本):改设定/吃书前,grep 正文+条目履历+时间线,列出"哪些章建立在这个事实上",分已发布/未发布两清单。未发布 → 直接改或吃书;**已发布 → 顺势圆**(生成向后兼容错误的圆设定方案——"已发布不可改"是网文铁律,顺势圆是主路径)。
 - **"回到第 N 章"**(人话命令):git 回滚的包装,自动模式跑废了一键回到批次起点或任意已定稿章。执行前展示影响范围,作者确认。
@@ -620,7 +624,7 @@ v6 的 8 个命令全部内化为以上状态。作者只需要一个入口和"
 
 ## 11. 派生缓存 `.cache/index.db`
 
-唯一允许的持久派生物(`node:sqlite`),gitignored,任何时刻可删。首查重建,重建器只读 定稿/大纲/文风 源文件。表(机器域,表名英文):`chapters`(front matter 展开,含章定位/钩子/情绪定位/收卷)、`threads`(三类条目统一存放,类型为列)、`secrets`(信息差,含"读者已知"与蓄积章数)、`entities`(名册与角色卡)、`entity_aliases`(别名 → 正名索引,供别名解析与唯一性校验)、`fingerprints`(文体指纹历史)。列定义以 `cache-design-2026-06-26.md` 为准。**重建器即格式的参考实现**——能完整重建,说明格式自洽。
+唯一允许的持久派生物(`node:sqlite`),gitignored,任何时刻可删。首查重建,重建器只读 定稿/大纲/文风 源文件。表(机器域,表名英文):`chapters`(front matter 展开,含章定位/钩子/情绪定位/收卷)、`threads`(三类条目统一存放,类型为列)、`secrets`(信息差,含"读者已知"与蓄积章数)、`entities`(名册与角色卡)、`entity_aliases`(别名 → 正名索引,供别名解析与唯一性校验)、`fingerprints`(文体指纹历史)。列定义以 `cache-design-2026-06-26.md` 为准。重建器重建六表结构并填充五类源索引,`fingerprints` 先清空、由体检按需确定性重算;它不是持久快照。**重建器即格式的参考实现**——能完整重建源索引,说明格式自洽。
 
 **改源流程自刷缓存**(决策 34):「缺失/损坏才重建」的正确前提是所有改源的路径自己负责刷新。定稿、回到第 N 章、吃书、卷复盘、修复回写、手改补登完成后由该流程立即重建缓存;刷新失败不阻断已完成的入档,但必须作废旧缓存(防陈旧章号/条目驱动后续判定),下次命令自动重建。
 
@@ -688,6 +692,13 @@ v6 的 8 个命令全部内化为以上状态。作者只需要一个入口和"
 
 ## 14. 决策记录
 
+### 0.17 → 0.18(2026-07-23 发布审计,owner 确认)
+
+58. **文体基线冻结配置章段**:`文体基线起/止` 必须形成有效正整数闭区间;区间每章都已定稿后才建立,禁止早期前缀基线。消费者只认当前配置的精确区间,作者改范围才切换。指纹仍是可删除派生物,缓存刷新后从同一章段确定性重算。
+59. **重试预算按章持久化**:首次机检失败后最多自动修复两轮;两审自动额度为初审一轮加整轮重审一次。草稿内容 hash(含章档案)/ReviewInput 令牌保证重取幂等,超限转作者,显式作者批准不重置自动额度;commit confirmed 或丢弃批次才精确清理。
+60. **PreToolUse 在 alpha 延后**:不实现、不安装,也不宣称写前会 ask。Claude SessionStart 只注入书单上下文,所有宿主由 `next` 序 2 在进入创作态前检测未登记手改;这是事后自愈。未来实现仍只能 ask,并需独立兼容性与安装测试。
+61. **story-repo-spec 不进入产品包**:完整稿、精简稿及重命名副本均只留开发仓库,不进入 npm tarball 或 `.webnovel/`;包内文档只允许知识治理文档与迁移指南。源码中的规范引用不等于分发规范文件。
+
 ### 0.16 → 0.17(2026-07-18 运行时框架一次性生效)
 
 45. **知识库采用十维唯一归属**:作品契约层为题材/流派/创意约束,故事对象层为设定/人物/命名,篇章执行层为节拍/场景/技法/追读。每条正式知识只有一个 canonical 路径;桥段、结构、兑现及物品/能力/组织/关系不建平行顶层。边界真源为 `v7/docs/knowledge/维度宪章.md`。
@@ -701,7 +712,7 @@ v6 的 8 个命令全部内化为以上状态。作者只需要一个入口和"
 53. **章级来源与整章变体分开追溯**:章档案的每个最终选择必须保存维度、名称和采用时来源;来源缺失的旧 v7 条目不双读。序 6 最近八章历史按章分组,逐项只补当前同版本条目的叙事功能与实现方式;来源删除或 hash 漂移时语义留空但谱系保留。`知识变体` 只作为 `本章整体变体`,不得复制到单个选择。
 54. **章级来源在细纲确认时冻结**:持久化层先校验作品契约,再原子写 `细纲.md` 与 schema 1 的 `细纲知识快照.json`;快照绑定细纲原文 SHA-256 和最终知识选择。后续通用条目修改/删除不改历史来源;细纲存在而快照缺失、旧版或不匹配时 fail closed。确实无细纲的批量转正只接受现行严格三段式选择与两段式变体档案。
 55. **事实裁决的执行语义属于选项**:每个冲突/歧义选项必须显式携带 `applyChange:boolean`。作者裁决保留原 options,以 `optionId` 唯一选择,执行层从命中项派生是否写入;false 对事实写入、计划删除、碰撞、暂存 payload 与叠加视图均为 no-op,畸形或篡改裁决 fail closed。
-56. **契约更新立即生效并持久守卫未发布工件**:单契约真源下 `生效起章` 必须等于下一未定稿章,更新原子失效全部当前未发布工件。机器 guard 以待重做与待提交证明覆盖生命周期;待提交证明绑定章号、契约版本、批次目录和原始定稿包 SHA-256,只有 commit confirmed 后精确释放,失败/未知/篡改/状态洗回均保留并阻断。既有 guard 未收口时禁止第二次更新。
+56. **契约更新立即生效并持久守卫未发布工件**:单契约真源下 `生效起章` 必须等于下一未定稿章,更新原子失效全部当前未发布工件,并把对应 `批次.json` 章状态赋为同一枚举中的 `契约变更`。机器 guard 以待重做与待提交证明覆盖生命周期;待提交证明绑定章号、契约版本、批次目录和原始定稿包 SHA-256,只有 commit confirmed 后精确释放,失败/未知/篡改/状态洗回均保留并阻断。既有 guard 未收口时禁止第二次更新。
 57. **两审绑定完整 ReviewInput 而非只绑定正文**:`review-input` 给包含章号、完整草稿、作品契约版本/内容及全部审稿上下文的 DTO 计算确定性令牌并写 sidecar;两份报告与 `save-review` 外层必须原样回传同一令牌。回流时在共享锁内重组当前 DTO 并与 sidecar、三处令牌逐一核对,审稿结果持久化已核验令牌与正文 hash;暂存、重审和定稿再核对完整章档案。契约更新前旧报告、同版本细纲/批内上下文变化、正文相同但 front matter 漂移及令牌混用均 fail closed,不保留旧格式兼容。
 
 ### 0.15 → 0.16(依据:2026-07-08 ima 蒸馏收编,作者逐项拍板,任务 07-08-kb-ima-distill-integration)
@@ -731,7 +742,7 @@ v6 的 8 个命令全部内化为以上状态。作者只需要一个入口和"
 
 | # | 变更 | 落点 | 来源 |
 |---|------|------|------|
-| 36 | 自动模式实现口径回填:批次目录三件套(草稿/定稿包/审稿单)+ 批次.json 三态(待审收/打回/受影响);污染传播=批次状态机,批量定稿入口硬校验"全待审收"(0.7 版留白至此收口);暂存前置"审稿单必须已生成"(砍的是逐章问作者,不是逐章审);批次质检判据钉死(批尾连续弱钩/缺书内时间/句式 vs 基线 30%·50% 容差),例行体检仍只算定稿、移到批量定稿成功后跑;"整批不要"从「回到第 N 章」更正为**丢弃批次**(批次未入 git,删工作区即回手动模式;goto 只管已定稿回退);§3 增 `连写无条目变动上限` 键 | §8.1、§3 | M6 任务(`.trellis/tasks/07-04-m6-auto-mode/`)决策 D2/D4/D5/D6/权衡记录;PRD §4 #14/#15 |
+| 36 | 自动模式实现口径回填:批次目录三件套(草稿/定稿包/审稿单)+ 污染传播三态(待审收/打回/受影响;决策 56 后 `契约变更` 作为同一章状态枚举的第四值由契约失效机制赋予);污染传播=批次状态机,批量定稿入口硬校验"全待审收"(0.7 版留白至此收口);暂存前置"审稿单必须已生成"(砍的是逐章问作者,不是逐章审);批次质检判据钉死(批尾连续弱钩/缺书内时间/句式 vs 基线 30%·50% 容差),例行体检仍只算定稿、移到批量定稿成功后跑;"整批不要"从「回到第 N 章」更正为**丢弃批次**(批次未入 git,删工作区即回手动模式;goto 只管已定稿回退);§3 增 `连写无条目变动上限` 键 | §8.1、§3 | M6 任务(`.trellis/tasks/07-04-m6-auto-mode/`)决策 D2/D4/D5/D6/权衡记录;PRD §4 #14/#15 |
 | 37 | 新开条目入档通道补缺:定稿 commit 明确含条目文件**创建**(M2 起 `threadUpdates` 只能更新既有文件,「埋下→定稿→下一章推进」会被机检误报不存在;修在定稿流程、手动/批次共用,不在暂存层单修——"事实变更只经定稿流程入 git"不开第二条路) | §8 第 8 步 | M6 任务决策 D7(实施期 07-04 发现,M2 起潜伏缺口) |
 
 ### 0.10 → 0.11(依据:2026-07-04 M5.5 体检里程碑)
@@ -783,4 +794,4 @@ v6 的 8 个命令全部内化为以上状态。作者只需要一个入口和"
 | 23 | 新增工作目录层:工作目录 ⊃ 书目录、唯一启动点、`books.jsonl` 登记 + hook 注入、书仓库指路 AGENTS.md;Node ≥ 22(node:sqlite) | §2.0、§2.2 | ADR 安装-1/2/3、技术-1 |
 | 24 | 时间线按卷拆分;`记忆/`→`摘要/`;新增导出命令、灵感池、精准读取接口、建书态;金句库自动收割移 7.x 附录 | §4.4/4.6/4.7、§6.2、§7、§10、§11.1 | ADR 格式-1/2/3、机制-5、原则-1、范围-1/2 |
 
-**落地备忘**(不进格式法律文本,实现时别丢):题材平台预设(番茄/起点节奏差异)、许可证 MIT/Apache-2.0、AI 味承诺口径("读者不出戏",不承诺过检测器)、每章 AI 调用次数预算上限(实现时定数)。
+**落地备忘**(不进格式法律文本,实现时别丢):题材平台预设(番茄/起点节奏差异)、许可证沿用根仓库 GPL v3 且 npm 包携带等值许可证文件、AI 味承诺口径("读者不出戏",不承诺过检测器)、每章 AI 调用次数预算上限(实现时定数)。

+ 1 - 1
docs/architecture/story-repo-spec-feedback-2026-06-11.md

@@ -44,4 +44,4 @@ v6 安装类 issue(#90 安装包、#103 找不到脚本、#69 环境配置)
 - 机检(内环第 5 步)建议补两个零 token 脚本检查:**跨章高频意象统计**("空气仿佛凝固"全书 47 次这类)、**句式体检**(句长方差/段落长度分布/高频句式开头)——格式不用动,纯机检项扩充。
 - 信息差砍掉"计划揭露"字段可接受:揭露可建为承诺(类型=悬念,兑付计划=揭露安排),机制自洽。
 - 上下文包/渲染层需前置"**反和解倾向**"规则(反派恶意落实处、冲突升级到底、禁说教式和解,按题材配浓度)——AI 对齐训练的副作用对爽文是毒药,事后审查修不了,只能写前反制。可进风格宪法或题材模板,不影响本规格。
-- 另一线已定但本规格未提的杂项,落地时别丢:题材平台预设(番茄/起点节奏差异)、许可证换 MIT/Apache-2.0、AI 味承诺口径("读者不出戏",不承诺过检测器)。
+- 另一线当时提出但本规格未提的杂项,落地时别丢:题材平台预设(番茄/起点节奏差异)、许可证换 MIT/Apache-2.0(历史建议,已由 2026-07-23 owner 决策“沿用根仓库 GPL v3”替代)、AI 味承诺口径("读者不出戏",不承诺过检测器)。

+ 1 - 1
docs/architecture/v7-design-discussion-notes-2026-06-11.md

@@ -77,6 +77,6 @@
 - 命令名英文/拼音 + 中文描述;所有文件 UTF-8 无 BOM,`.gitattributes` 锁 LF;不用 .bat/.sh。
 - 字数统计按字符数自己写(`wc -w` 对中文无效)。
 - 写手与审稿分离(自己审自己会自我辩护),"新鲜上下文"靠独立命令/会话实现。
-- 许可证建议 MIT/Apache-2.0(v7 从零写,无 GPL 传染问题)。
+- 许可证曾建议 MIT/Apache-2.0(历史讨论,已由 2026-07-23 owner 决策“沿用根仓库 GPL v3”替代)。
 - 合规/敏感词不进 v1。
 - 直接采纳的用户需求:定点修复(#78/#99)、连写(#79)、手改重读(#100/#77)。

+ 3 - 2
docs/architecture/v7-implementation-plan.md

@@ -1,8 +1,9 @@
 # webnovel-writer v7 实施计划(里程碑路线图)
 
-> 状态:草案 0.3(2026-07-02 同步设计边界回顾:新增 M5.5 体检里程碑、M5 补 F1 宿主 CLI 缝清单;0.2 于 2026-06-26 反映 RFC 后续决策、2026-06-27 同步 D3/D4)
+> 状态:历史草案 0.3(2026-07-02 同步设计边界回顾:新增 M5.5 体检里程碑、M5 补 F1 宿主 CLI 缝清单;0.2 于 2026-06-26 反映 RFC 后续决策、2026-06-27 同步 D3/D4)
 > 上游:`v7-prd.md` 1.1、`story-repo-spec-2026-06-10.md` 0.9、`multi-agent-adaptation-spec-2026-06-05.md` v3.5、`.trellis/spec/backend/` 基线 1.0
 > 地位:本文档管**实施排程**——把 PRD §6 的 v7.0 范围切成有依赖顺序、有出口判据的里程碑。范围与行为以 PRD/spec 为准,两者冲突时改本文档。
+> 历史边界:本文停在 2026-07-02 的实施前排程快照,其中“v7 代码 0 行”等状态已失效,不得作为当前进度或现行契约;当前行为以 PRD 1.7、story repo spec 0.18、`.trellis/spec/backend/` 与 `v7/src/` 为准。
 
 ---
 
@@ -111,7 +112,7 @@ steps:
 
 ### M1 格式层核心库 + 派生缓存(RFC 收口后第一批)
 
-- **第一步消解 O4(设计文档,需单独任务)**:`.cache/index.db` 五表(chapters/threads/secrets/entities/fingerprints)DDL + 精准读取接口完整清单(spec 0.7 §11.1 初版清单的补全),成稿后补进 spec §11
+- **第一步消解 O4(历史排程)**:当时按五表(chapters/threads/secrets/entities/fingerprints)规划;现行规范与实现已增加 `entity_aliases`,统一为六表。精准读取接口完整清单随后补进 spec §11
 - 容错读写库:front matter / 平铺 YAML / 三类条目文件 / book.yaml;未知字段保留原样写回(不变量 9);写出防呆(平铺、块列表、危险值加引号)
 - 重建器:只读 定稿/大纲/文风 全量重建缓存——**重建器即格式的参考实现**;履历证据引用验证章节文件存在(RFC 决策 A3)
 - 精准读取接口 CLI 全套(条目/大纲/正文/时间线/设定/全文检索/报表)

+ 18 - 10
docs/architecture/v7-prd.md

@@ -1,6 +1,6 @@
 # webnovel-writer v7 产品需求文档(PRD)
 
-> 状态:1.6(2026-07-18 修订,十维知识运行时与 story repo spec 0.17 一次性生效:作品契约成为唯一书级真源,计划对象到定稿事实形成闭环,章级知识按四维少量候选随整份细纲确认,审稿统一为事实审查+编辑审两审。1.5 于 2026-07-16 冻结十维治理;1.4 于 2026-07-08 修订关系结算五档,已被 1.5 删除;1.3 于 2026-07-07 修订旧声明路由,已被 1.5/1.6 替代;1.2 于 2026-07-04 修订 §4 #2 验收用词;1.1 于 2026-07-02 修订 §5 依赖口径;1.0 于 2026-06-12 作者逐项确认定稿)
+> 状态:1.7(2026-07-23 发布审计决策:文体基线完整区间后冻结;机检自动修复两轮、两审自动重跑一轮且按章持久计数;alpha 延后 PreToolUse;story-repo-spec 完全不随 npm 分发。1.6 同日勘误自动连写批次默认 8 章且每批体检、手动体检周期默认 50 章,并统一 GPL v3。2026-07-18 的 1.6 令十维知识运行时与 story repo spec 0.17 一次性生效;更早历史见 git)
 > 日期:2026-06-12
 > 读者:①开发实施者(含 AI);②issue 区用户(RFC 母本,裁剪时换大白话)
 > 地位:本 PRD 是 v7 的产品法律文本。spec 0.5 的"冻结"已解除——本 PRD 先行,`story-repo-spec` 与 `multi-agent-adaptation-spec` 随本文档修订(修订指令见 §10)。
@@ -41,7 +41,7 @@ v6 的病根(issue 与代码互证):
 1. **文件即真相**:作者面对的一切状态都是中文 Markdown,可手改、可 git diff。系统检测手改并提议入账,永不报错拒绝。
 2. **脚本能做的归脚本,做不到的归 AI 语义判断——一定要分清楚**。禁止用正则硬凑语义判断(一点用没有);能数的不让模型估,能模型判的不打扰作者。
 3. **git 隐身铁律**:作者永远不需要亲手敲 git 命令——真需要 git 操作时由 AI 代为运行;任何 git 异常都有人话修复流程。
-4. **可靠性来自自愈,不来自门禁**:宁停勿崩;hook 语义是 ask 不是 deny(#113 教训)。
+4. **可靠性来自自愈,不来自门禁**:宁停勿崩。7.0.0-alpha 不安装 PreToolUse;未来若实现,hook 语义也只能是 ask 而不是 deny(#113 教训)。
 5. **精准读取,非必要不全文读**:所有数据文件都有"精准读到所需一段"的脚本命令;AI 默认读片段,不整文件读取(v6 token 失控的根治手段)。
 6. **作者界面零机器味**:术语全部用网文圈原生词或大白话(§8 术语表);财务/工程/数据库隐喻和自造精简词禁止出现在作者界面。
 7. **品味决策归人**:**定稿永远由作者敲定(没有"自动定稿")**;金句入库、文风规则入铁律,一律作者审核,不交给阈值或模型。
@@ -82,7 +82,7 @@ v6 的病根(issue 与代码互证):
 
 ```text
 ① 前提        当前卷卷纲已确认(自动模式的控制边界——全自动 ≠ 无控制,是控制上移到大纲层)
-② 开关        自动确认细纲 + 连写批次大小(默认与体检周期一致 8 章;开关中文名 spec 定稿时敲定)
+② 开关        自动确认细纲 + 连写批次大小(默认 8 章;自动模式每批体检,手动体检周期默认 50 章)
 ③ 挂机        连写一个批次:草稿与预入账数据攒在工作区,**不定稿**;
               写后续章的材料从"定稿 + 待定稿批次"叠加组装(脚本提供)
 ④ 停止        批次写满 / 体检不过线 / 卷纲耗尽(绝不让模型裸奔编纲)
@@ -116,7 +116,7 @@ v6 的病根(issue 与代码互证):
 - **分发只走 npx**(`npx webnovel-writer init` / `update`)。Claude Code 插件市场渠道放弃,市场版停在 v6 并指引新装法。
 - `update` 用模板哈希追踪:用户没改过的文件直接更新,改过的提示(Trellis 模式)。
 - **当前书与书单由登记文件解析**(`.webnovel/books.jsonl`,机器域,每行一本书:书名/目录/是否当前在写/最后打开):
-  - **SessionStart hook 注入上下文**(Claude Code):会话启动自动注入"当前在写哪本、共几本书、全书近况入口";无 hook 宿主由状态机入口启动时读同一文件,行为等价——关键能力不得只存在于 hook 中。
+  - **SessionStart hook 注入上下文**(Claude Code):会话启动只注入“当前在写哪本、共几本书、全书近况入口”。无 hook 宿主由 SKILL 显式调用同一上下文命令;所有宿主执行 `next` 时再由状态机序 2 检测未登记手改。后者是事后自愈,不是 PreToolUse 写前拦截;关键能力不得只存在于 hook 中。
   - 换书 = 一句话让 AI 改登记(对话即编辑器);作者不需要手编 jsonl。
   - 自愈:登记文件缺失/损坏 → 扫描工作目录下含 book.yaml 的子目录**重建书单**(书单是可重建派生物),"当前在写"标记请作者选一次。
 - **只支持从工作目录启动**:各宿主只在启动目录自动加载 skills/配置(`.claude/`、`.codex/` 都住工作目录),在书目录里启动插件根本不会加载。当前书一律由登记文件决定。脚本启动时校验当前目录含 `.webnovel/`,不是工作目录 → 人话提示"请从工作目录启动"。
@@ -235,6 +235,14 @@ v6 的 8 个命令全部内化为以上状态;作者只需要一个入口和"
 
 `.cache/index.db` 的表设计必须支撑这些查询;写作材料组装默认用精准片段。
 
+### 3.7 质量预算、文体基线与分发边界
+
+- **文体基线**:`book.yaml` 的 `文体基线起..文体基线止` 是闭区间,必须为正整数且起不大于止。区间内每章都已定稿后才建立基线;未齐全时不写部分前缀、不做漂移比较。建立后章段不随新章扩大,作者修改区间才切换。指纹是可删除缓存,缓存刷新后允许从同一固定章段确定性重算。
+- **机检修复预算**:每章首次机检失败后,宿主最多自动改稿并重检两轮;同一草稿重复检查幂等,不重复计数。两轮后仍失败必须停下交作者,不得继续自动消耗写稿 token。
+- **两审预算**:一次完整两审是一对调用(事实审查 1 + 编辑审 1),兼容模式是一轮单上下文顺序审。每章自动额度为初审一轮加整轮重审一次;再次两审必须由作者明确批准。冲突/歧义经作者裁决后以同一 ReviewInput 和报告重提 `save-review`,不算新两审。
+- **持久化与清理**:预算按章写入 `工作区/重试预算.json`,跨会话、宿主和批内重审保留;定稿提交确认后清该章,丢弃批次清该批章。记录损坏时宁停勿猜,不能静默恢复额度。
+- **规范分发**:`story-repo-spec` 的完整稿、精简稿及重命名副本均只属于开发仓库,完全不进入 npm tarball 或安装后的 `.webnovel/`。包内文档只允许 `docs/knowledge/**` 和 `docs/migration-guide.md`。
+
 ---
 
 ## 4. 功能需求:问题空间 16 项的产品化映射
@@ -249,13 +257,13 @@ v6 的 8 个命令全部内化为以上状态;作者只需要一个入口和"
 | 6 | 大纲漂移 | 偏离是决策点不是 bug:细纲阶段给"拉回/改纲/进灵感池"三选 | 7.0 | 细纲模板含偏离报告段 |
 | 7 | 改稿三档 | 未发布:直接改+自动重入账;已发布:只读,生成"顺势圆"方案;设定/大纲:影响分析出已发布/未发布两清单 | 7.0 | 三档各有端到端用例 |
 | 8 | 无成本洗白或无动机让渡底线 | 不设题材默认强硬档位;`作品契约.md` 的“冲突与关系结算原则”明确主角底线、伤害后果、和解条件、救赎条件与允许余地。善良、宽恕和关系修复均合法,编辑审只核对动机、责任、代价、边界和读者承诺 | 7.0 | 合理宽恕/无成本洗白/有代价救赎/无动机让渡底线四类样例可区分 |
-| 9 | 桥段循环 | 高频意象统计(零 token 脚本)+ 节拍/场景/技法/追读近期选择史的软降权与重复提醒;有递进、回响或回收目的时允许复用 | 7.0 | 高频意象能被机检报出;同名章级知识只降权不硬排除 |
+| 9 | 桥段循环 | 高频意象统计(零 token 脚本)+ 节拍/场景/技法/追读近期选择史的软降权与重复提醒;文体基线只在配置区间完整后建立并固定章段;有递进、回响或回收目的时允许复用 | 7.0 | 不完整基线不参与比较;完整基线不随新章扩大;同名章级知识只降权不硬排除 |
 | 10 | 情绪节奏失控 | 每章情绪定位入 front matter(7.0)+ 波形体检与平台预设周期(7.x) | 7.0/7.x | 体检报告含情绪波形图 |
 | 11 | AI 味 | 分布问题分层打:禁词表(第一层)+ 句式体检脚本(7.0)+ 文风锚定 few-shot(7.x)。**诚实边界:承诺"读者不出戏",不承诺过 AI 检测器** | 7.0/7.x | 句式体检指标进体检报告 |
 | 12 | 对话同质化 | 口癖卡 + 遮名盲测 | 7.x | 盲测用例 |
 | 13 | 诚实边界 | README/RFC 明示承诺口径;合规/敏感词不进 v1 | 全程 | 文档口径一致 |
 | 14 | 控制点滑杆 | 确认细纲/审稿两个开关;全关=逐章交互,全开=自动模式;一套实现 | 7.0 | 开关组合矩阵全测 |
-| 15 | 连写质量漂移 | **按批次定稿**(错误最多污染一个批次,且未定稿可直接改)+ 体检与批次对齐(默认 8 章)+ 停止条件(写满/体检不过线/卷纲耗尽/连续 3 章无账本变动)+ 已发布错误走顺势圆 | 7.0 | 自动模式端到端 + 注入错误的恢复演练 |
+| 15 | 连写质量漂移 | **按批次定稿**(错误最多污染一个批次,且未定稿可直接改)+ 自动模式每批体检(连写批次默认 8 章,不复用手动模式默认 50 章的 `体检周期`)+ 停止条件(写满/体检不过线/卷纲耗尽/连续 3 章无账本变动)+ 已发布错误走顺势圆 | 7.0 | 自动模式端到端 + 注入错误的恢复演练 |
 | 16 | 真源唯一 | 正文+摘要 markdown 即全部状态;`.cache` 删了全量重建 | 7.0 | 重建 CI(重建器即格式的参考实现) |
 
 ---
@@ -266,12 +274,12 @@ v6 的 8 个命令全部内化为以上状态;作者只需要一个入口和"
 |---|---|
 | 运行时 | Node ≥ 22.13.0;运行时直接依赖仅 `js-yaml`(YAML 解析;序列化手写防呆),其余零依赖,缓存用内置 `node:sqlite`(决策记录:M1 任务 design §8.1);安装器检测版本并人话提示 |
 | 平台 | Windows 中文环境一等公民:全链路 UTF-8 无 BOM、`core.quotepath false`、CI 含 Windows 中文路径全链路测试(建库→写章→定稿→重建缓存) |
-| Token 成本 | 机检零 token;精准读取默认片段化;分层摘要控制长程上下文;完整两审每章 2 次 AI 调用,降级模式 1 次审稿上下文 |
+| Token 成本 | 机检零 token;首次机检失败后最多自动修复两轮;精准读取默认片段化;完整两审初审 2 次 AI 调用且最多自动整对重跑一次(每章最多 4 次),降级模式初审 1 个顺序审上下文且最多自动重跑一次(每章最多 2 个);超限转作者 |
 | 健壮性 | 永不带堆栈崩溃(修复确认兜底);定稿原子性;git 健康检查;容错读取(未知字段保留原样写回) |
 | 可诊断性 | 强制项目级安装,目录结构全网统一;issue 里贴目录清单即可定位 |
 | 防呆 | 系统写出的 YAML 一律平铺、块列表、危险值加引号;多条记录每条一文件 |
-| 多宿主 | 格式层平台无关;SKILL.md 开放标准零适配;角色单源生成三平台壳 + drift check;降级诚实(不允许声称调用了不存在的能力);hook 语义 ask 不 deny |
-| 许可证 | MIT/Apache-2.0(v7 从零写) |
+| 多宿主 | 格式层平台无关;SKILL.md 开放标准零适配;角色单源生成三平台壳 + drift check;降级诚实(不允许声称调用了不存在的能力);alpha 只安装 Claude SessionStart,不安装 PreToolUse,`next` 序 2 负责事后手改自愈;未来 hook 仍只能 ask 不 deny |
+| 许可证 | 沿用根仓库 GPL v3;v7 npm 包使用同一授权口径并随包携带等值许可证文件 |
 
 ---
 
@@ -369,7 +377,7 @@ v6 的 8 个命令全部内化为以上状态;作者只需要一个入口和"
 1. §0/§1:删"棘轮"比喻;不变量补三条——git 隐身铁律、精准读取、"脚本/AI 语义分界"。
 2. §2:目录按 §3.2 修订(账本三目录、摘要/、时间线按卷、细纲/审稿/导出、工作区文件改名);**条目编号改中文全称**("编号保留 ASCII"条款废止);新增"工作目录 ⊃ 书目录"安装布局、书目录登记(`.webnovel/books.jsonl`)、**启动点限制**(只支持从工作目录启动——宿主只在启动目录加载 skills/配置;脚本校验 cwd 含 `.webnovel/`,否则人话提示)。
 3. §2.1:Node ≥ 22,`node:sqlite`。
-4. §3 book.yaml:**删除"自动验收"开关**(定稿永远归作者),新增"连写批次大小"(默认与体检周期一致);开关中文名随术语表(O2);新增分类型"悬了太久"阈值。
+4. §3 book.yaml:**删除"自动验收"开关**(定稿永远归作者),新增"连写批次大小"(当时曾要求与体检周期同值;现行口径已改为连写默认 8 章且每批体检、手动 `体检周期` 默认 50 章,二者不是同一配置);开关中文名随术语表(O2);新增分类型"悬了太久"阈值。
 5. §5:大一统"承诺"拆三类账本;"每章必须结转"硬门检改**声明制**(细纲声明本章定位);履历行必须引用章内证据;删豁免机制。
 6. §6.1 改名文风铁律;§6.2 金句库 7.0 仅目录+手动,自动收割条款移入 7.x 附录(候选制+对话触发+作者审核)。
 7. §7 决策卡 → 细纲.md,固定段落改为:全书近况 / 本章提案与定位声明 / 本章要写到的事 / 备选。

+ 674 - 0
v7/LICENSE

@@ -0,0 +1,674 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Use with the GNU Affero General Public License.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    <program>  Copyright (C) <year>  <name of author>
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.

+ 1 - 1
v7/adapters/codex/support.md

@@ -5,5 +5,5 @@
 - skill:支持,SKILL.md;`description` 受约 8k 字符预算
 - subagent:实验性支持,TOML agents
 - hook:不支持
-- 降级策略:无 hook → 状态机入口读 `books.jsonl` 等价注入;subagent 实验性 → 两审优先完整,不稳时降级兼容模式并声明
+- 降级策略:无 hook → 生成的 SKILL 启动步显式运行 `session-context` 注入当前书/书单/近况入口;subagent 实验性 → 两审优先完整,不稳时降级兼容模式并声明
 - smoke:`node scripts/smoke.mjs --host codex`(推迟 beta 手测:建书→写1章→两审→定稿)

+ 4 - 4
v7/bin/webnovel-writer.js

@@ -1,7 +1,7 @@
 #!/usr/bin/env node
 
 import path from 'node:path'
-import { fileURLToPath } from 'node:url'
+import { fileURLToPath, pathToFileURL } from 'node:url'
 import { readFileSync } from 'node:fs'
 import { CacheManager } from '../src/cache/index.js'
 import { checkNodeVersion } from '../src/runtime/node-version.js'
@@ -50,10 +50,10 @@ if (!command || command === '--help') {
   console.log('')
   console.log('写章流程(M2,零 AI 脚本面):')
   console.log('  prepare-chapter <章号>                  备料:写出 工作区/本章写作材料.md')
-  console.log('  mechanical-check <章号> [--draft=<路径>]  机检:字数/禁词/禁句式/复读/新专名/信息差候选')
+  console.log('  mechanical-check <章号> [--draft=<路径>] [--author-confirmed]  机检:字数/禁词/禁句式/复读/新专名/信息差候选;自动修复每章 2 轮,超限需作者确认')
   console.log('')
   console.log('宿主通道(M5,AI 产物经文件回流;JSON 一律 --file/--payload 文件路径):')
-  console.log('  review-input <章号> [--draft=<路径>]      生成含契约版本/令牌的 工作区/审稿输入.json')
+  console.log('  review-input <章号> [--draft=<路径>] [--author-approved]  生成含契约版本/令牌的 工作区/审稿输入.json;两审自动轮次每章 2 轮,超限需作者批准')
   console.log('  save-review <章号> --file=<两审json> [--draft=<路径>]  外层+两份报告原样回传令牌(禁止重算)→ 工作区/审稿.md')
   console.log('  persist-outline --file=<json>            细纲落盘({细纲})')
   console.log('  persist-book --file=<json> [--dir=<目录>] 建书落盘+登记(作品契约必需,须作者确认)')
@@ -116,7 +116,7 @@ if (!/^[a-z0-9-]+$/.test(command)) {
 let cache
 try {
   const commandPath = path.join(__dirname, '../src/commands', `${command}.js`)
-  const commandUrl = new URL(`file:///${commandPath.replace(/\\/g, '/')}`).href
+  const commandUrl = pathToFileURL(commandPath).href
   const mod = await import(commandUrl)
 
   const { options, positionalArgs } = parseArgs(argv.slice(1))

+ 2 - 0
v7/docs/migration-guide.md

@@ -43,6 +43,8 @@
 | 总纲/详细大纲/剧情线 | `大纲/总纲.md` + `大纲/卷纲/` |
 | 设定集 | `定稿/设定/` 原样 |
 | 记忆/文风套路 | 「迁移待校对-」文件,人工过一遍再入 |
+| Dashboard 常驻界面 | 不迁;v7 用单入口状态机、书仓文件和 `report-*` 命令查看进度与质量信号 |
+| Dashboard 实体图谱 | 不迁图形投影;实体与别名转入名册/角色卡,关系与变化留在角色卡/时间线,缓存可从这些真源重建 |
 | 追读力债务台账、向量库等派生数据 | 不迁(报告里如实列出) |
 
 ## 常见问题

+ 3 - 2
v7/package-lock.json

@@ -1,12 +1,13 @@
 {
   "name": "webnovel-writer",
-  "version": "0.0.0",
+  "version": "7.0.0-alpha",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "webnovel-writer",
-      "version": "0.0.0",
+      "version": "7.0.0-alpha",
+      "license": "GPL-3.0-only",
       "dependencies": {
         "js-yaml": "^5.2.0"
       },

+ 4 - 1
v7/package.json

@@ -2,6 +2,7 @@
   "name": "webnovel-writer",
   "version": "7.0.0-alpha",
   "description": "AI 网文写作工作流(v7 重写,M5 发版前预发版号)",
+  "license": "GPL-3.0-only",
   "type": "module",
   "engines": {
     "node": ">=22.13.0"
@@ -10,6 +11,7 @@
     "webnovel-writer": "bin/webnovel-writer.js"
   },
   "files": [
+    "LICENSE",
     "bin/",
     "src/",
     "roles/",
@@ -17,7 +19,8 @@
     "skills/",
     "adapters/",
     "templates/",
-    "docs/"
+    "docs/knowledge/",
+    "docs/migration-guide.md"
   ],
   "scripts": {
     "test": "node --test",

+ 76 - 2
v7/scripts/pack-install-e2e.mjs

@@ -32,14 +32,41 @@ const exists = async (p) => {
     return false
   }
 }
+const listRelativeFiles = async (root) => {
+  const files = []
+  const walk = async (dir, base = '') => {
+    for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
+      const rel = base ? `${base}/${entry.name}` : entry.name
+      if (entry.isDirectory()) await walk(path.join(dir, entry.name), rel)
+      else if (entry.isFile()) files.push(rel)
+    }
+  }
+  await walk(root)
+  return files
+}
+const parsePackResult = (stdout) => {
+  const parsed = JSON.parse(stdout)
+  return Array.isArray(parsed) ? parsed[0] : Object.values(parsed)[0]
+}
 
 const cleanups = []
 try {
   // 1. pack(发布产物 = npx 消费的同一份包内容)
   const packDest = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-pack-'))
   cleanups.push(packDest)
-  const packOut = await npm(['pack', '--pack-destination', packDest], { cwd: pkgRoot })
-  const tgzName = packOut.stdout.trim().split('\n').at(-1).trim()
+  const packOut = await npm(['pack', '--json', '--pack-destination', packDest], { cwd: pkgRoot })
+  const pack = parsePackResult(packOut.stdout)
+  const packFiles = (pack?.files || []).map((entry) => (typeof entry === 'string' ? entry : entry.path))
+  const packedDocs = packFiles.filter((rel) => rel.startsWith('docs/'))
+  check(packFiles.includes('LICENSE'), 'pack 清单含 GPL 许可证')
+  check(packFiles.includes('docs/migration-guide.md'), 'pack 清单含迁移指南')
+  check(
+    packedDocs.every((rel) => rel.startsWith('docs/knowledge/') || rel === 'docs/migration-guide.md'),
+    'pack 清单 docs 仅含 knowledge 与 migration-guide',
+  )
+  check(!packFiles.some((rel) => rel.toLowerCase().includes('story-repo-spec')), 'pack 清单不含 story-repo-spec')
+  check(!packFiles.some((rel) => rel.startsWith('docs/architecture/')), 'pack 清单不含 docs/architecture')
+  const tgzName = pack?.filename
   const tgz = path.join(packDest, tgzName)
   check(await exists(tgz), `npm pack 产物:${tgzName}`)
 
@@ -48,13 +75,31 @@ try {
   cleanups.push(sandbox)
   await npm(['install', tgz, '--prefix', sandbox, '--no-audit', '--no-fund'], { cwd: sandbox })
   const installedBin = path.join(sandbox, 'node_modules', 'webnovel-writer', 'bin', 'webnovel-writer.js')
+  const installedRoot = path.join(sandbox, 'node_modules', 'webnovel-writer')
   check(await exists(installedBin), '安装产物 bin 存在')
+  const installedFiles = await listRelativeFiles(installedRoot)
+  const installedDocs = installedFiles.filter((rel) => rel.startsWith('docs/'))
+  check(
+    installedDocs.every((rel) => rel.startsWith('docs/knowledge/') || rel === 'docs/migration-guide.md'),
+    '安装包 docs 仅含 knowledge 与 migration-guide',
+  )
+  check(!installedFiles.some((rel) => rel.toLowerCase().includes('story-repo-spec')), '安装包不含 story-repo-spec')
+  check(!installedFiles.some((rel) => rel.startsWith('docs/architecture/')), '安装包不含 docs/architecture')
 
   // 3. 中文用户名模拟路径里 init(一条命令装出工作目录)
   const base = await fs.mkdtemp(path.join(os.tmpdir(), '中文用户名-'))
   cleanups.push(base)
   const workdir = path.join(base, '工作目录')
   await fs.mkdir(workdir, { recursive: true })
+  const userPreToolUse = [
+    { matcher: 'Write|Edit', hooks: [{ type: 'command', command: 'node 用户自己的写前检查.mjs' }] },
+  ]
+  await fs.mkdir(path.join(workdir, '.claude'), { recursive: true })
+  await fs.writeFile(
+    path.join(workdir, '.claude', 'settings.json'),
+    JSON.stringify({ hooks: { PreToolUse: userPreToolUse } }, null, 2) + '\n',
+    'utf8',
+  )
   const node = (bin, args) =>
     exec(process.execPath, [bin, ...args], { cwd: workdir, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
 
@@ -78,6 +123,25 @@ try {
   ]) {
     check(await exists(path.join(workdir, rel)), `init 布局:${rel}`)
   }
+  const initializedFiles = await listRelativeFiles(path.join(workdir, '.webnovel'))
+  const initializedDocs = initializedFiles.filter((rel) => rel.startsWith('docs/'))
+  check(
+    initializedDocs.every((rel) => rel.startsWith('docs/knowledge/')),
+    'init 后 .webnovel/docs 仅含知识治理文档',
+  )
+  check(!initializedFiles.some((rel) => rel.toLowerCase().includes('story-repo-spec')), 'init 后不含 story-repo-spec')
+  check(!initializedFiles.some((rel) => rel.startsWith('docs/architecture/')), 'init 后不含 docs/architecture')
+  const initializedSettings = JSON.parse(await fs.readFile(path.join(workdir, '.claude/settings.json'), 'utf8'))
+  check(
+    JSON.stringify(initializedSettings.hooks.PreToolUse) === JSON.stringify(userPreToolUse),
+    'init 保留用户自有 PreToolUse',
+  )
+  check(
+    Array.isArray(initializedSettings.hooks.SessionStart)
+      && initializedSettings.hooks.SessionStart.length === 1
+      && initializedSettings.hooks.SessionStart[0]?.hooks?.[0]?.command?.endsWith('session-context'),
+    'alpha 只新增 SessionStart 上下文 hook',
+  )
 
   // 4. vendored bin 建第一本书(干净环境一条命令装出并建书 = AC1 链路)
   const vendoredBin = path.join(workdir, '.webnovel', 'bin', 'webnovel-writer.js')
@@ -154,6 +218,16 @@ try {
   // 6. update 幂等重跑
   const up = await node(installedBin, ['update'])
   check(/升级完成|安装完成/.test(up.stdout), 'update 幂等可重跑')
+  const afterUpdateSettings = JSON.parse(await fs.readFile(path.join(workdir, '.claude/settings.json'), 'utf8'))
+  check(
+    JSON.stringify(afterUpdateSettings.hooks.PreToolUse) === JSON.stringify(userPreToolUse),
+    'update 保留用户自有 PreToolUse',
+  )
+  check(
+    Array.isArray(afterUpdateSettings.hooks.SessionStart)
+      && afterUpdateSettings.hooks.SessionStart.length === 1,
+    'update 不重复安装 SessionStart',
+  )
 
   if (failed) {
     console.error('\n安装链路 e2e 存在失败项')

+ 6 - 4
v7/skills/webnovel-writer/SKILL.md

@@ -11,9 +11,10 @@ description: 中文长篇网文写作单入口。说「继续/写下一章/建
 SessionStart 已注入「当前在写哪本 / 共几本 / 全书近况入口」。
 {{/if}}
 {{#unless hasHooks}}
-启动先运行 `{{cmd}} session-context`,向作者报「当前在写哪本 / 共几本」。
+启动先运行 `{{cmd}} session-context`,向作者报「当前在写哪本 / 共几本 / 全书近况入口」。
 {{/unless}}
 换书说一声即可:`{{cmd}} switch-book <书名>`;看书单:`{{cmd}} list-books`。
+所有宿主的 `next` 序2只做事后手改自愈,不是 PreToolUse 写前拦截;7.0.0-alpha 不安装 PreToolUse。
 
 ## 单入口:判定下一步
 作者说「继续」,运行 `{{cmd}} next --json`(git 健康检查先行),按返回的 `序` 执行:
@@ -21,8 +22,8 @@ SessionStart 已注入「当前在写哪本 / 共几本 / 全书近况入口」
 - 序1 建书:问答收集书名、类型、副题材、流派、主角、核心机制和结局;按 `dto.作者状态分流` 行动:作者已有明确构想时只确认归一并补真实缺口,不强制发散;想法模糊或完全空白时先问目标体验、驱动力、规模、偏好与禁区,再给少量整体方向包,不展示十维全量菜单。`dto.知识路由` 只用于名称归一。运行 `{{cmd}} knowledge-pack --类型=<主题材> --副题材=<a,b> --流派=<a,b>` 取书级材料,只有真实未决的创意问题才追加 `{{cmd}} knowledge-query --维度=创意约束 --问题=<问题>`。副题材非空时,逐项完成材料包中的“题材融合协议检查”,不能用标签露出次数代替因果融合。与作者对撞后生成完整作品契约:front matter 含类型/副题材/流派/创意约束/来源版本/契约版本/生效起章/更新原因/变更类型,正文含核心读者承诺、骨架约定、题材融合协议、创意约束落地、差异化点(至少 3 条)、冲突与关系结算原则、本书专属毒点、节奏与兑现参数。作者确认后产出 `{"book","总纲","卷纲","作品契约","知识选择","实际裁决"?,"作者已确认":true}`,运行 `{{cmd}} persist-book --file=<json路径>`。
 - 序2 手改补登:向作者出示 `dto.变更文件` 问「补登吗」,确认后运行 `{{cmd}} relink --message=<一句话说明>`。
 - 序3 断点续跑:按 `dto.从哪继续` 回到写章流程对应步骤。
-- 序5 体检:按返回的 `message` 指引执行。
 - 序4 卷复盘:先用 `dto.作品契约` 复盘当前有效契约;若 DTO 带 `阻断原因`,停止复盘并先修复契约。正常时吃 DTO 与作者对谈,产出 `{"卷号","卷摘要","下卷卷纲","伏笔条目"}`,运行 `{{cmd}} persist-volume-review --file=<json路径>`;同时复盘作品契约,无问题就保持不变,有问题只提出修订候选。作者确认新版本后才生成含 `作品契约`、`知识选择`、`证据`、`影响范围`、`作者已确认:true` 的更新 JSON;核心分类或创意约束变化还须带 `影响分析已确认:true`,再运行 `{{cmd}} persist-contract --file=<json路径>`。
+- 序5 体检:按返回的 `message` 指引执行。
 - 序6 起草细纲:先用 `dto.作品契约` 约束本章提案;若 DTO 带 `阻断原因`,停止起草并先修复契约。吃 DTO 拟整份细纲提案(本章定位 + 本章要写到的事 + 备选)。`dto.章级知识候选` 每维最多三条,只是材料,不是固定答案;可组合、变体、拒绝或自定义。按 `dto.近期知识比较要求` 逐章读取 `dto.近期知识历史`:在各章 `选择` 内比较名称、叙事功能和实现方式,只把 `本章整体变体` 当作该章上下文,禁止臆造它与单个选择的归属;同名但有实质推进可复用,不同标签却同质落地要提醒,提醒不得压过本章匹配度。提案段按需写 `本章节拍:`、`本章场景:`、`本章技法:`、`本章追读:`、`知识变体:`、`本章对象:`,六者皆可空,前四者可多选,变体可多行。作者确认整份细纲后产出 `{"细纲"}`,运行 `{{cmd}} persist-outline --file=<json路径>`;若 DTO 带作品契约复盘提示,只呈报修订候选,不自动改契约,确认修订时仍须记录证据和影响范围。
 
 ## 故事对象
@@ -30,8 +31,9 @@ SessionStart 已注入「当前在写哪本 / 共几本 / 全书近况入口」
 
 ## 写章流程
 1. 备料:`{{cmd}} prepare-chapter <章号>`,读 `工作区/本章写作材料.md` 写草稿到 `工作区/草稿-A.md`。
-2. 机检:`{{cmd}} mechanical-check <章号>`,修可计数项后重跑至过线。
+2. 机检:`{{cmd}} mechanical-check <章号>`,按返回 JSON 的 `action` 执行:`revise-and-recheck` → 修可计数项后重检(自动修复每章最多两轮);`continue-to-review` → 进两审;`hand-off-to-author` 或 `request-author-confirmation` → 停止自动改稿交作者,作者同意继续时加 `--author-confirmed` 再检(单独记账,不恢复自动额度)。
 3. 两审:`{{cmd}} review-input <章号>` 生成 `工作区/审稿输入.json`;其中 `作品契约版本` 与 `审稿输入令牌` 由脚本绑定完整输入。
+   两审自动额度每章为初审一轮加自动重审一轮;`review-input` 提示超限被拒时停下交作者,作者明确批准重审后加 `--author-approved` 重发(单独记账,不恢复自动额度)。
 {{#if agentCapable}}
    派两个独立 subagent 按 `事实审查`、`编辑审` 任务书各读同一份审稿输入,各自新鲜上下文出报告。
 {{/if}}
@@ -39,7 +41,7 @@ SessionStart 已注入「当前在写哪本 / 共几本 / 全书近况入口」
    兼容模式——按 `事实审查`、`编辑审` 两份任务书顺序自审,`mode` 填 `degraded`。
 {{/unless}}
    两份报告各在顶层逐字原样回传 ReviewInput 的 `审稿输入令牌`,禁止重算、改写或省略。
-   两份报告合成 `{"审稿输入令牌","事实审查","编辑审","mode","待确认新专名","章摘要"}`;外层令牌同样逐字复制 ReviewInput,且必须与两份报告内令牌相等。运行 `{{cmd}} save-review <章号> --file=<json路径>`;审稿单落 `工作区/审稿.md`,交作者:接受 / 改完接受 / 打回。事实审查的 `factChanges` 若含冲突或歧义,必须带 `difference`、`impact`、`options[{optionId,label,description?,applyChange}]`;每个选项必须显式写布尔值 `applyChange`,`false` 表示不写事实且保留计划,`true` 表示执行该事实变化,禁止按 `optionId` 或文案猜测。按命令返回向作者呈报差异、影响与处理选项。作者裁决后原样保留这些字段与选项,把对应项改为 `作者已裁决`,同时写明 `resolution` 与所选 `optionId` 后重新提交,不得静默覆盖。
+   两份报告合成 `{"审稿输入令牌","事实审查","编辑审","mode","待确认新专名","章摘要"}`;外层令牌同样逐字复制 ReviewInput,且必须与两份报告内令牌相等。运行 `{{cmd}} save-review <章号> --file=<json路径>`;审稿单落 `工作区/审稿.md`,交作者:接受 / 改完接受 / 打回。事实审查的 `factChanges` 若含冲突或歧义,必须带 `difference`、`impact`、`options[{optionId,label,description?,applyChange}]`;每个选项必须显式写布尔值 `applyChange`,`false` 表示不写事实且保留计划,`true` 表示执行该事实变化,禁止按 `optionId` 或文案猜测。按命令返回向作者呈报差异、影响与处理选项。作者裁决后原样保留这些字段与选项,把对应项改为 `作者已裁决`,同时写明 `resolution` 与所选 `optionId` 后重新提交,不得静默覆盖;裁决重提使用同一审稿输入令牌,不消耗新的两审轮次。
 4. 定稿:作者敲定后组定稿包(`frontMatter`、`body`、`summary`、`threadCreates`(本章「埋下/设下/开启」的新条目,`{id, 短题, frontMatter, body}`)、`threadUpdates`、`characterUpdates`、`rosterUpserts`(`类型` 用中文:`角色`/`地点`/`组织`/`物品`)、`timelineRows`、`secretWrites`、事实审查给出的 `factChanges`、`commitLines`、`workspaceFiles`——本章用过的工作区文件全列进 `workspaceFiles`),运行 `{{cmd}} finalize <章号> --payload=<json路径>`,再运行 `{{cmd}} next --json` 进下一步。`知识选择` 由脚本从确认细纲归档,不让定稿包另写一份答案。
 
 ## 自动模式(连写,作者说「连写/挂机写一批」才进入)

+ 1 - 1
v7/src/cache/index.js

@@ -26,7 +26,7 @@ export async function refreshCacheAfterSourceChange(ctx) {
 }
 
 /**
- * CacheManager:管理 .cache/index.db 五表。
+ * CacheManager:管理 .cache/index.db 六表(含 entity_aliases)。
  */
 export class CacheManager {
   constructor(dbPath) {

+ 3 - 1
v7/src/commands/batch-discard.js

@@ -8,8 +8,10 @@ import { discardBatch } from '../staging/index.js'
 export async function run(args, options, ctx) {
   const r = await discardBatch(ctx.repoPath)
   if (!r.ok) return { ok: false, error: r.error }
+  const lines = [`已丢弃整批 ${r.章数} 章(未定稿,定稿区零变化)。运行 next 从起草细纲重新开始。`]
+  if (Array.isArray(r.warnings)) lines.push(...r.warnings.map((w) => `提醒:${w}`))
   return {
     ok: true,
-    output: `已丢弃整批 ${r.章数} 章(未定稿,定稿区零变化)。运行 next 从起草细纲重新开始。`,
+    output: lines.join('\n'),
   }
 }

+ 91 - 6
v7/src/commands/mechanical-check.js

@@ -1,8 +1,20 @@
+import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { mechanicalCheck } from '../mechanical-check/index.js'
+import { writeAtomicBatch } from '../storage/atomic.js'
+import { acquireContractMutationLock } from '../staging/contract-invalidation.js'
+import {
+  mechanicalBudgetForChapter,
+  readRetryPolicy,
+  recordMechanicalAttempt,
+  reserveMechanicalAttempt,
+  retryDraftHash,
+  retryPolicyFile,
+} from '../retry-policy/index.js'
 
 /**
- * mechanical-check <章号> [--draft=<path>] → JSON {pass, issues, candidates}(机检,零 token)
+ * mechanical-check <章号> [--draft=<path>] [--author-confirmed]
+ * → JSON {pass, issues, candidates, used, remaining, blocked, action}(机检,零 token)
  * 默认草稿 工作区/草稿-A.md。契约:纯返回 {ok, output?, error?}(见 design §6.2)。
  */
 export async function run(args, options, ctx) {
@@ -15,10 +27,83 @@ export async function run(args, options, ctx) {
       ? path.resolve(ctx.repoPath, options.draft)
       : path.join(ctx.repoPath, '工作区', '草稿-A.md')
 
-  const r = await mechanicalCheck(ctx, { chapterNum, draftPath })
-  if (!r.ok) return { ok: false, error: r.error }
-  return {
-    ok: true,
-    output: JSON.stringify({ pass: r.pass, issues: r.issues, candidates: r.candidates }, null, 2),
+  const lock = await acquireContractMutationLock(ctx.repoPath, '机检重试记账')
+  if (!lock.ok) return { ok: false, error: lock.error }
+  try {
+    const before = await fs.readFile(draftPath, 'utf8')
+    const draftHash = retryDraftHash(before)
+    const loaded = await readRetryPolicy(ctx.repoPath)
+    if (!loaded.ok) return { ok: false, error: loaded.error }
+
+    const reserved = reserveMechanicalAttempt(loaded.state, {
+      chapterNum,
+      draftHash,
+      authorConfirmed: options['author-confirmed'] === true || options['author-confirmed'] === 'true',
+    })
+    if (!reserved.ok) return { ok: false, error: `机检重试预算无法核对:${reserved.error}` }
+    if (!reserved.allowed) {
+      // 未执行机检就没有 pass 结论(§2.4 pass=issues.length===0 是消费方契约),
+      // 拒检分支只输出预算与去向。
+      return {
+        ok: true,
+        output: JSON.stringify(
+          {
+            executed: false,
+            used: reserved.budget.used,
+            remaining: reserved.budget.remaining,
+            blocked: true,
+            action: reserved.action,
+            message: reserved.error,
+          },
+          null,
+          2
+        ),
+      }
+    }
+
+    const r = await mechanicalCheck(ctx, { chapterNum, draftPath })
+    if (!r.ok) return { ok: false, error: r.error }
+    const after = await fs.readFile(draftPath, 'utf8')
+    if (retryDraftHash(after) !== draftHash) {
+      return { ok: false, error: '机检期间草稿发生了变化,结果未记账;请对当前草稿重新运行机检。' }
+    }
+
+    const recorded = recordMechanicalAttempt(loaded.state, {
+      chapterNum,
+      draftHash,
+      pass: r.pass,
+      route: reserved.route,
+    })
+    if (!recorded.ok) return { ok: false, error: `机检重试预算无法记账:${recorded.error}` }
+    if (recorded.changed) {
+      await writeAtomicBatch(ctx.repoPath, [retryPolicyFile(recorded.state)])
+    }
+    const budget = mechanicalBudgetForChapter(recorded.state, chapterNum)
+    const action = r.pass
+      ? 'continue-to-review'
+      : budget.remaining > 0
+        ? 'revise-and-recheck'
+        : 'hand-off-to-author'
+    return {
+      ok: true,
+      output: JSON.stringify(
+        {
+          pass: r.pass,
+          issues: r.issues,
+          candidates: r.candidates,
+          used: budget.used,
+          remaining: budget.remaining,
+          blocked: r.pass ? false : budget.blocked,
+          action,
+          executed: true,
+        },
+        null,
+        2
+      ),
+    }
+  } catch (err) {
+    return { ok: false, error: `机检重试记账失败:${err.message}` }
+  } finally {
+    await lock.release()
   }
 }

+ 37 - 6
v7/src/commands/report-style-drift.js

@@ -1,24 +1,27 @@
+import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
+import { configuredBaselineRange, readCurrentBaselineFingerprint } from '../health-check/baseline.js'
+
 /**
  * report-style-drift → 当前指纹 vs 基线的差异
  * fingerprints 表由体检填充(M5.5):基线段 + 最近周期章段各一行。
  * 表为空 → 返回友好中文错误。契约:纯返回 {ok, output?, error?}(见 design §6.2)。
  */
 export async function run(args, options, ctx) {
-  const baseline = await ctx.cache.query(
-    'SELECT * FROM fingerprints WHERE is_baseline = 1 ORDER BY chapter_range_end DESC LIMIT 1'
-  )
+  const config = await new BookConfigReader(ctx.repoPath).read()
+  if (!config.ok) return { ok: false, error: config.error }
+  const baseline = await readCurrentBaselineFingerprint(ctx.cache, config.data)
   const recent = await ctx.cache.query(
     'SELECT * FROM fingerprints WHERE is_baseline = 0 ORDER BY chapter_range_end DESC LIMIT 1'
   )
 
-  if (baseline.length === 0 || recent.length === 0) {
+  if (!baseline || recent.length === 0) {
     return {
       ok: false,
-      error: '缺少指纹数据:fingerprints 表为空或不全,请先运行体检以提取文体特征。',
+      error: await explainNoDrift(ctx.cache, config.data, baseline),
     }
   }
 
-  const b = baseline[0]
+  const b = baseline
   const r = recent[0]
   const drift = {
     基线章段: [b.chapter_range_start, b.chapter_range_end],
@@ -31,3 +34,31 @@ export async function run(args, options, ctx) {
   }
   return { ok: true, output: JSON.stringify(drift, null, 2) }
 }
+
+/**
+ * 没有可比数据时说清成因和作者该做什么:区间还没写满时再跑体检也建不出基线,
+ * 只有区间已齐却没跑过体检才该去跑体检(错误处理规范 §4.2)。
+ */
+async function explainNoDrift(cache, config, baseline) {
+  const range = configuredBaselineRange(config)
+  if (!range) {
+    return '文体基线配置无效:book.yaml 的「文体基线起」「文体基线止」都要写正整数,且起不能大于止,改好后再跑一次体检。'
+  }
+  const 应有 = range.end - range.start + 1
+  const 已定稿 = await countFinalized(cache, range)
+  if (已定稿 < 应有) {
+    return `文体基线尚未建立:配置区间为第 ${range.start}-${range.end} 章,当前只定稿了其中 ${已定稿}/${应有} 章。区间内每章都定稿后跑一次体检才会建立基线,在那之前没有文风漂移可比。`
+  }
+  if (baseline) {
+    return `暂无可比的近段:文体基线(第 ${range.start}-${range.end} 章)已建立,但还没有基线区间之外的定稿章。写过第 ${range.end} 章后再跑一次体检,就能看到漂移。`
+  }
+  return '缺少指纹数据:fingerprints 表为空或不全,请先运行体检以提取文体特征。'
+}
+
+async function countFinalized(cache, range) {
+  const rows = await cache.query(
+    'SELECT COUNT(*) AS c FROM chapters WHERE chapter_num >= ? AND chapter_num <= ?',
+    [range.start, range.end]
+  )
+  return rows[0]?.c || 0
+}

+ 24 - 4
v7/src/commands/review-input.js

@@ -3,10 +3,13 @@ import { assembleReviewInput } from '../review/index.js'
 import { writeAtomicBatch } from '../storage/atomic.js'
 import { reviewInputFile } from '../review/input-binding.js'
 import { acquireContractMutationLock } from '../staging/contract-invalidation.js'
+import { MAX_AUTOMATIC_REVIEW_ATTEMPTS, reserveReviewAttempt } from '../retry-policy/index.js'
 
 /**
- * review-input <章号> [--draft=<repo相对路径>]:组装两审共用的 ReviewInput 并落
- * 工作区/审稿输入.json(含草稿全文、作品契约版本和完整输入的确定性令牌)。
+ * review-input <章号> [--draft=<repo相对路径>] [--author-approved]:组装两审共用的
+ * ReviewInput 并落 工作区/审稿输入.json(含草稿全文、作品契约版本和完整输入的确定性令牌)。
+ * 签发前在同一作品状态锁内预约 工作区/重试预算.json 的两审轮次:自动轮次上限
+ * 每章 2 轮,超限必须带 --author-approved(单独记账,不恢复自动额度)。
  * 缺省草稿 工作区/草稿-A.md(与 mechanical-check 一致)。
  * 契约:纯返回 {ok, output?, error?}。
  */
@@ -15,6 +18,8 @@ export async function run(args, options, ctx) {
   if (isNaN(chapterNum)) return { ok: false, error: '章号必须是数字' }
   const draftPath =
     options.draft && options.draft !== true ? options.draft : path.join('工作区', '草稿-A.md')
+  const authorApproved =
+    options['author-approved'] === true || options['author-approved'] === 'true'
 
   const lock = await acquireContractMutationLock(ctx.repoPath, '组装审稿输入')
   if (!lock.ok) return { ok: false, error: lock.error }
@@ -22,11 +27,26 @@ export async function run(args, options, ctx) {
     const r = await assembleReviewInput(ctx, { chapterNum, draftPath })
     if (!r.ok) return { ok: false, error: r.error }
 
+    const reserved = await reserveReviewAttempt(ctx.repoPath, {
+      chapterNum,
+      reviewInputToken: r.input.审稿输入令牌,
+      mode: 'pending',
+      authorApproved,
+    })
+    if (!reserved.ok) return { ok: false, error: reserved.error }
+
     const file = reviewInputFile(r.input)
-    await writeAtomicBatch(ctx.repoPath, [file])
+    const files = [file]
+    if (reserved.file) files.push(reserved.file)
+    await writeAtomicBatch(ctx.repoPath, files)
+    const budgetNote = reserved.idempotent
+      ? `当前轮次已签发,未消耗新额度;自动轮次已用 ${reserved.used}/${MAX_AUTOMATIC_REVIEW_ATTEMPTS}`
+      : reserved.attempt?.route === 'author'
+        ? `作者批准的额外轮次,已单独记账;自动轮次已用 ${reserved.used}/${MAX_AUTOMATIC_REVIEW_ATTEMPTS}`
+        : `两审自动轮次已用 ${reserved.used}/${MAX_AUTOMATIC_REVIEW_ATTEMPTS}`
     return {
       ok: true,
-      output: `已写出 ${file.path}(两审共用同一份输入;含 拟条目变动 ${r.input.拟条目变动.length} 项、相关角色 ${r.input.相关角色.length} 个)`,
+      output: `已写出 ${file.path}(两审共用同一份输入;含 拟条目变动 ${r.input.拟条目变动.length} 项、相关角色 ${r.input.相关角色.length} 个;${budgetNote})`,
     }
   } finally {
     await lock.release()

+ 1 - 0
v7/src/commands/save-review.js

@@ -41,6 +41,7 @@ export async function run(args, options, ctx) {
   const lines = [
     `审稿单已落 ${path.join('工作区', '审稿.md')}:共 ${r.merged.issues_count} 个问题,${r.merged.blocking_count} 个阻断${r.merged.has_blocking ? '(有阻断,需处理后再定稿)' : ''}`,
   ]
+  if (Array.isArray(r.warnings)) lines.push(...r.warnings.map((w) => `提醒:${w}`))
   const factDecisionRequest = formatFactChangeDecisionRequests(
     r.merged.事实审查?.factChanges
   )

+ 3 - 2
v7/src/commands/session-context.js

@@ -1,8 +1,9 @@
 import { assembleSessionContext, touchLastOpened } from '../session/index.js'
 
 /**
- * session-context:输出 SessionStart 注入文本(当前在写哪本/共几本/入口)。
- * Claude Code SessionStart hook 与无 hook 宿主状态机入口都调这里 → 注入逐字一致。
+ * session-context:输出会话上下文(当前在写哪本/共几本/入口)。
+ * Claude Code SessionStart 与无 hook 宿主生成壳显式调用这里;next 的序 2 是另一条
+ * 事后手改自愈路径,不冒充写前 hook。
  * 契约:纯返回 {ok, output?, error?}。
  */
 export const scope = 'workdir'

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

@@ -32,6 +32,8 @@ import {
   verifyPendingCommitProofFile,
   workspaceRemovalIsContained,
 } from '../staging/contract-invalidation.js'
+import { clearRetryPolicyChapters } from '../retry-policy/index.js'
+import { writeAtomicBatch } from '../storage/atomic.js'
 
 /**
  * 定稿:原子 commit(D3)。写工作树 → git add → commit → 最后清工作区。
@@ -310,6 +312,19 @@ async function finalizeChapterLocked(ctx, payload, opts = {}) {
     }
     warnings.push(...await cleanWorkspaceFiles(repoPath, workspaceFiles))
 
+    // 章节已入档:清该章重试预算(决策 2026-07-23)。失败只记 warning,
+    // 残留只会让下次重写多要一次作者确认,不影响已提交结果。
+    try {
+      const retryCleared = await clearRetryPolicyChapters(repoPath, [chapterNum])
+      if (!retryCleared.ok) {
+        warnings.push(`本章已入档,但重试预算清理失败:${retryCleared.error}`)
+      } else if (retryCleared.file) {
+        await writeAtomicBatch(repoPath, [retryCleared.file])
+      }
+    } catch (err) {
+      warnings.push(`本章已入档,但重试预算清理失败(${err.message}),稍后手动删除该章记录即可。`)
+    }
+
     return { ok: true, commitHash, cacheRefresh, contractGuardReleased, warnings, error: '' }
   } catch (err) {
     if (commitConfirmed) {

+ 42 - 0
v7/src/health-check/baseline.js

@@ -0,0 +1,42 @@
+/**
+ * Return the configured closed interval used by all style-baseline consumers.
+ * Invalid values are treated as unavailable so callers fail closed instead of
+ * falling back to a historical fingerprint.
+ *
+ * @param {object|null|undefined} config Parsed book.yaml data
+ * @returns {{start: number, end: number}|null}
+ */
+export function configuredBaselineRange(config) {
+  const start = config?.文体基线起
+  const end = config?.文体基线止
+  if (!Number.isInteger(start) || start < 1) return null
+  if (!Number.isInteger(end) || end < 1 || start > end) return null
+  return { start, end }
+}
+
+/**
+ * Read only the active fingerprint for the current configured interval.
+ * Historical baseline rows are intentionally ignored; health-check is the
+ * sole producer and may remove them when the configuration changes.
+ *
+ * @param {{query: Function}} cache
+ * @param {object|null|undefined} config Parsed book.yaml data
+ * @returns {Promise<object|null>}
+ */
+export async function readCurrentBaselineFingerprint(cache, config) {
+  const range = configuredBaselineRange(config)
+  if (!range) return null
+  try {
+    const rows = await cache.query(
+      `SELECT * FROM fingerprints
+       WHERE is_baseline = 1
+         AND chapter_range_start = ?
+         AND chapter_range_end = ?
+       LIMIT 1`,
+      [range.start, range.end]
+    )
+    return rows[0] || null
+  } catch {
+    return null
+  }
+}

+ 36 - 15
v7/src/health-check/index.js

@@ -5,6 +5,7 @@ import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
 import { TimelineReader } from '../storage/adapters/TimelineReader.js'
 import { parseFrontMatter } from '../storage/parsers/front-matter.js'
 import { extractImagery, extractFingerprint, styleMetrics } from '../style-stats/index.js'
+import { configuredBaselineRange } from './baseline.js'
 
 // 高频意象入 meta/报告的条数(提醒不拦截,硬编码合理默认)
 const IMAGERY_TOP = 20
@@ -41,9 +42,8 @@ export async function runHealthCheck(ctx) {
 
     const config = await new BookConfigReader(repoPath).read()
     const bookConfig = config.ok ? config.data : {}
+    const baselineRange = config.ok ? configuredBaselineRange(bookConfig) : null
     const 体检周期 = bookConfig.体检周期 || 50
-    const 基线起 = bookConfig.文体基线起 || 1
-    const 基线止 = bookConfig.文体基线止 || 30
     // 近段窗口只依赖书状态不依赖 meta:缓存/体检记录丢失不改变体检输出(确定性)
     const 近段起 = Math.max(1, maxChapter - 体检周期 + 1)
 
@@ -69,6 +69,10 @@ export async function runHealthCheck(ctx) {
       corpusFail = `定稿正文读取失败:${err.message}`
     }
 
+    // 基线是当前 book.yaml 区间的派生物。配置切换或区间未齐时,先移除所有旧 active 行,
+    // 避免任何消费者继续读到历史基线;语料读不到时本轮无从判断该不该清,保留已有基线。
+    if (!corpusFail) await cache.run('DELETE FROM fingerprints WHERE is_baseline = 1')
+
     let 意象节 = ''
     let 句式节 = ''
     let 指纹节 = ''
@@ -109,8 +113,8 @@ export async function runHealthCheck(ctx) {
       // 文体指纹:基线段 + 近段各算一份 upsert 入表,报告出漂移对比
       try {
         const r = await compareFingerprints(cache, corpus, exclude, {
-          基线起,
-          基线止,
+          baselineRange,
+          baselineConfigError: config.ok ? '' : config.error,
           近段起,
           maxChapter,
         })
@@ -199,23 +203,40 @@ async function readExcludeNames(cache) {
   return names
 }
 
-async function compareFingerprints(cache, corpus, exclude, { 基线起, 基线止, 近段起, maxChapter }) {
-  if (maxChapter < 基线起) {
-    return { text: `- 章数不足基线区间(基线从第 ${基线起} 章起),暂不对比。`, data: null }
+async function compareFingerprints(
+  cache,
+  corpus,
+  exclude,
+  { baselineRange, baselineConfigError, 近段起, maxChapter }
+) {
+  if (!baselineRange) {
+    return {
+      text: `- 文体基线配置无效${baselineConfigError ? `:${baselineConfigError}` : ''},尚未建立基线。`,
+      data: null,
+    }
   }
-  const 基线终 = Math.min(基线止, maxChapter)
+  const { start: 基线起, end: 基线止 } = baselineRange
   const inRange = (s, e) => corpus.filter((c) => c.num >= s && c.num <= e)
 
-  const baseFp = extractFingerprint(inRange(基线起, 基线终), exclude)
-  await upsertFingerprint(cache, 基线起, 基线终, 1, baseFp)
-  const 基线 = fpSummary(基线起, 基线终, baseFp)
+  const baselineChapters = inRange(基线起, 基线止)
+  const expectedCount = 基线止 - 基线起 + 1
+  if (baselineChapters.length !== expectedCount) {
+    return {
+      text: `- 文体基线尚未建立:配置区间为第 ${基线起}-${基线止} 章,当前仅找到 ${baselineChapters.length}/${expectedCount} 章,暂不比较。`,
+      data: null,
+    }
+  }
+
+  const baseFp = extractFingerprint(baselineChapters, exclude)
+  await upsertFingerprint(cache, 基线起, 基线止, 1, baseFp)
+  const 基线 = fpSummary(基线起, 基线止, baseFp)
 
   // 基线段与近段完全重合(全书尚在基线区间内):只落基线行,避免同主键行被近段覆盖掉基线标记
-  if (基线起 === 近段起 && 基线终 === maxChapter) {
+  if (基线起 === 近段起 && 基线止 === maxChapter) {
     return {
       text: [
         '- 全书尚在基线区间内,基线与近段重合,暂无漂移可比。',
-        `- 基线(第 ${基线起}-${基线终} 章):${fpLine(baseFp)}`,
+        `- 基线(第 ${基线起}-${基线止} 章):${fpLine(baseFp)}`,
       ].join('\n'),
       data: { 基线, 近段: null, delta: null },
     }
@@ -231,10 +252,10 @@ async function compareFingerprints(cache, corpus, exclude, { 基线起, 基线
     词汇丰富度: recentFp.vocabulary_richness - baseFp.vocabulary_richness,
   }
   const text = [
-    `- 基线(第 ${基线起}-${基线终} 章):${fpLine(baseFp)}`,
+    `- 基线(第 ${基线起}-${基线止} 章):${fpLine(baseFp)}`,
     `- 近段(第 ${近段起}-${maxChapter} 章):${fpLine(recentFp)}`,
     `- 漂移:平均句长 ${deltaLine(delta.平均句长, baseFp.avg_sentence_length, '字')},句长方差 ${deltaLine(delta.句长方差, baseFp.sentence_length_variance, '')},平均段长 ${deltaLine(delta.平均段长, baseFp.avg_paragraph_length, '字')},词汇丰富度 ${signed(delta.词汇丰富度, 3)}`,
-    `- 若感觉文风漂了:回读基线章(第 ${基线起}-${基线终} 章)找回手感;若新写法更合心意:把 book.yaml 里的 文体基线起/文体基线止 改成新章段。这一步由你决定,脚本不自动改。`,
+    `- 若感觉文风漂了:回读基线章(第 ${基线起}-${基线止} 章)找回手感;若新写法更合心意:把 book.yaml 里的 文体基线起/文体基线止 改成新章段。这一步由你决定,脚本不自动改。`,
   ].join('\n')
   return { text, data: { 基线, 近段, delta } }
 }

+ 5 - 1
v7/src/installer/index.js

@@ -199,7 +199,11 @@ function buildReport({ workdir, version, hosts, detected, registry, written, ski
       const tier = cfg.tier ? `${cfg.tier === 1 ? '一' : cfg.tier === 2 ? '二' : '三'}级${cfg.verified ? `(${cfg.verified})` : ''}` : ''
       const cap = []
       cap.push(cfg.agentCapable ? '两审=独立 subagent' : '两审=兼容模式(顺序自审,如实声明)')
-      cap.push(cfg.hasHooks ? '启动自动注入书单(SessionStart)' : '启动由入口读书单(与 hook 等价)')
+      cap.push(
+        cfg.hasHooks
+          ? 'SessionStart 只注入当前书、本数与全书近况入口'
+          : '启动时由 SKILL 显式运行 session-context'
+      )
       lines.push(`  - ${h} → ${cfg.install_dir}/  ${tier};${cap.join(';')}${detected.includes(h) ? '' : '(未在 PATH 检测到,按既装/指定生成)'}`)
     }
   } else {

+ 8 - 4
v7/src/installer/shells.js

@@ -20,7 +20,7 @@ export async function buildShellFiles(packageRoot, registry, hosts) {
 
 /**
  * claude-code SessionStart hook 接线:对 .claude/settings.json 做保留式合并——
- * 只按 command 字符串幂等追加本项,用户已有 hooks/权限配置原样保留。
+ * 只在 SessionStart 内按 command 精确判重,用户已有 hooks/权限配置原样保留。
  * settings.json 是用户主权文件,不进哈希清单,update 重复调用无副作用。
  * @returns {{content: string, changed: boolean, error?: string}}
  */
@@ -36,11 +36,15 @@ export function mergeClaudeSettings(existingText, command) {
       return { content: existingText, changed: false, error: 'settings.json 结构异常,跳过 hook 接线' }
     }
   }
-  if (JSON.stringify(settings).includes(command)) {
-    return { content: JSON.stringify(settings, null, 2) + '\n', changed: false }
-  }
   if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {}
   if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = []
+  const alreadyInstalled = settings.hooks.SessionStart.some((group) =>
+    Array.isArray(group?.hooks)
+    && group.hooks.some((hook) => hook?.type === 'command' && hook.command === command)
+  )
+  if (alreadyInstalled) {
+    return { content: JSON.stringify(settings, null, 2) + '\n', changed: false }
+  }
   settings.hooks.SessionStart.push({ hooks: [{ type: 'command', command }] })
   return { content: JSON.stringify(settings, null, 2) + '\n', changed: true }
 }

+ 14 - 0
v7/src/knowledge/contract.js

@@ -1,4 +1,5 @@
 import { parseFrontMatter } from '../storage/parsers/front-matter.js'
+import { 文体基线默认区间 } from '../storage/parsers/book-config.js'
 
 export const WORK_CONTRACT_PATH = '作品契约/作品契约.md'
 export const KNOWLEDGE_SELECTION_PATH = '作品契约/知识选择记录.md'
@@ -70,6 +71,7 @@ export function validateCreateBookPayload(payload) {
   if (!stringValue(book.类型)) errors.push('book 需含归一后的「类型」')
   validateStringList(book.副题材, 'book.副题材', errors, { optional: true })
   validateStringList(book.流派, 'book.流派', errors, { optional: true })
+  validateBaselineRange(book, errors)
   if (!stringValue(payload.总纲)) errors.push('JSON 需含非空字符串字段「总纲」')
   if (!stringValue(payload.卷纲)) errors.push('JSON 需含非空字符串字段「卷纲」')
   if (payload.作者已确认 !== true) errors.push('作品契约尚未得到作者确认,不能完成建书')
@@ -361,6 +363,18 @@ export function renderContractSections(contract, sectionNames) {
   return parts.join('\n\n')
 }
 
+// 文体基线区间随 book.yaml 一起落盘,写盘前按解析侧同一语义校验(storage/parsers/book-config.js):
+// 缺字段套解析默认值,出现即须是正整数且起不大于止,非法值不进 book.yaml。
+function validateBaselineRange(book, errors) {
+  const start = book.文体基线起 === undefined ? 文体基线默认区间.文体基线起 : book.文体基线起
+  const end = book.文体基线止 === undefined ? 文体基线默认区间.文体基线止 : book.文体基线止
+  const startOk = Number.isInteger(start) && start >= 1
+  const endOk = Number.isInteger(end) && end >= 1
+  if (!startOk) errors.push('book「文体基线起」必须是正整数')
+  if (!endOk) errors.push('book「文体基线止」必须是正整数')
+  if (startOk && endOk && start > end) errors.push('book「文体基线起」不能大于「文体基线止」')
+}
+
 function compareBookFields(book, fm, errors) {
   if (stringValue(book.类型) !== stringValue(fm.类型)) {
     errors.push('book.类型 与作品契约.类型不一致')

+ 4 - 11
v7/src/mechanical-check/index.js

@@ -5,6 +5,7 @@ import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
 import { parseThreadDeclarations, VERBS, OPENING_VERBS } from '../util/thread-declarations.js'
 import { styleMetrics, AVG_SENTENCE_LEN_TOLERANCE, SENTENCE_VARIANCE_TOLERANCE } from '../style-stats/index.js'
 import { stagedFacts } from '../staging/index.js'
+import { readCurrentBaselineFingerprint } from '../health-check/baseline.js'
 
 // front matter 章档案必填字段(§4.1 机器消费部分)
 const REQUIRED_FM = ['章号', '标题', '卷', '字数', '章定位', '钩子', '情绪定位']
@@ -44,7 +45,7 @@ export async function mechanicalCheck(ctx, { chapterNum, draftPath }) {
     await checkSecretKeywords(body, cache, candidates, staged) // 7(候选)
     await checkThreadDeclarations(fm, cache, issues, staged) // 8(条目变动,只查形式)
     await checkImageryHits(body, cache, candidates) // 9(候选,消费体检的高频意象清单)
-    await checkStyleDeviation(body, cache, candidates) // 10(候选,vs 基线指纹)
+    await checkStyleDeviation(body, cache, candidates, bookConfig) // 10(候选,vs 当前配置基线指纹)
 
     return { ok: true, pass: issues.length === 0, issues, candidates, error: '' }
   } catch (err) {
@@ -252,16 +253,8 @@ async function checkImageryHits(body, cache, candidates) {
 }
 
 // 本章句式 vs 基线指纹(体检 upsert 的基线行):平均句长偏 ≥30% 或句长方差偏 ≥50% → 非阻断提醒;无基线 → 静默跳过
-async function checkStyleDeviation(body, cache, candidates) {
-  let base = null
-  try {
-    const rows = await cache.query(
-      'SELECT avg_sentence_length, sentence_length_variance FROM fingerprints WHERE is_baseline = 1 ORDER BY chapter_range_end DESC LIMIT 1'
-    )
-    base = rows[0] || null
-  } catch {
-    return
-  }
+async function checkStyleDeviation(body, cache, candidates, config) {
+  const base = await readCurrentBaselineFingerprint(cache, config)
   if (!base) return
   const m = styleMetrics(body)
   if (base.avg_sentence_length > 0) {

+ 580 - 0
v7/src/retry-policy/index.js

@@ -0,0 +1,580 @@
+import { createHash } from 'node:crypto'
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+
+/**
+ * Persistent retry accounting is deliberately a small, deterministic machine
+ * record.  The forward-slash path is part of the product contract even on
+ * Windows; node's fs/path APIs accept it unchanged.
+ */
+export const RETRY_POLICY_PATH = '工作区/重试预算.json'
+export const RETRY_POLICY_SCHEMA_VERSION = 1
+export const MAX_MECHANICAL_AUTO_REPAIRS = 2
+export const MAX_AUTOMATIC_REVIEW_ATTEMPTS = 2
+
+const HASH_RE = /^sha256:[0-9a-f]{64}$/
+const REVIEW_MODES = new Set(['pending', 'complete', 'degraded'])
+const MECHANICAL_ROUTES = new Set(['initial', 'automatic', 'author'])
+const REVIEW_ROUTES = new Set(['automatic', 'author'])
+const MECHANICAL_RESULTS = new Set(['pass', 'fail'])
+const REVIEW_STATUSES = new Set(['issued', 'saved'])
+
+/** Return a fresh, valid empty state. */
+export function emptyRetryPolicy() {
+  return { schemaVersion: RETRY_POLICY_SCHEMA_VERSION, chapters: {} }
+}
+
+/**
+ * Read and strictly validate the retry record.  A missing record is the valid
+ * empty state; every other read/parse/schema failure is fail-closed.
+ */
+export async function readRetryPolicy(repoPath) {
+  let raw
+  try {
+    raw = await fs.readFile(path.join(repoPath, RETRY_POLICY_PATH), 'utf8')
+  } catch (err) {
+    if (err.code === 'ENOENT') {
+      return { ok: true, state: emptyRetryPolicy(), exists: false, error: '' }
+    }
+    return { ok: false, state: null, exists: true, error: `重试预算记录无法读取:${err.message}` }
+  }
+
+  let value
+  try {
+    value = JSON.parse(raw)
+  } catch (err) {
+    return { ok: false, state: null, exists: true, error: `重试预算记录损坏,已停止自动流程:${err.message}` }
+  }
+  const checked = validateRetryPolicy(value)
+  if (!checked.ok) {
+    return { ok: false, state: null, exists: true, error: `重试预算记录损坏,已停止自动流程:${checked.error}` }
+  }
+  return { ok: true, state: checked.state, exists: true, error: '' }
+}
+
+/**
+ * Build the only supported on-disk representation.  This function never
+ * includes timestamps, process ids, or derived counters.
+ */
+export function retryPolicyFile(state) {
+  const checked = validateRetryPolicy(state)
+  if (!checked.ok) throw new Error(`重试预算记录内容不完整,拒绝写入:${checked.error}`)
+  return {
+    path: RETRY_POLICY_PATH,
+    content: JSON.stringify(checked.state, null, 2),
+  }
+}
+
+/**
+ * Hash of the whole draft file (front matter included, line endings
+ * normalized).  Front-matter-only fixes are still repairs, so they must
+ * produce a new hash and consume budget; body-only hashing would allow
+ * unlimited free rechecks on front-matter issues.
+ */
+export function retryDraftHash(content) {
+  const normalized = String(content || '').replace(/\r\n?/g, '\n').trim()
+  return 'sha256:' + createHash('sha256').update(normalized, 'utf8').digest('hex')
+}
+
+/** Validate and return a canonical copy of the state. */
+export function validateRetryPolicy(value) {
+  if (!isPlainObject(value)) return { ok: false, state: null, error: '顶层必须是对象' }
+  if (exactKeys(value, ['schemaVersion', 'chapters']) === false) {
+    return { ok: false, state: null, error: '顶层字段必须且只能是 schemaVersion、chapters' }
+  }
+  if (value.schemaVersion !== RETRY_POLICY_SCHEMA_VERSION) {
+    return { ok: false, state: null, error: `schemaVersion 必须是 ${RETRY_POLICY_SCHEMA_VERSION}` }
+  }
+  if (!isPlainObject(value.chapters)) return { ok: false, state: null, error: 'chapters 必须是对象' }
+
+  const chapterNumbers = Object.keys(value.chapters)
+  const canonicalChapters = {}
+  for (const key of chapterNumbers.sort(compareChapterKeys)) {
+    if (!isCanonicalChapterKey(key)) {
+      return { ok: false, state: null, error: `章号键无效:${key}` }
+    }
+    const chapter = value.chapters[key]
+    const validChapter = validateChapterRecord(chapter, key)
+    if (!validChapter.ok) return validChapter
+    canonicalChapters[key] = validChapter.chapter
+  }
+  return {
+    ok: true,
+    state: { schemaVersion: RETRY_POLICY_SCHEMA_VERSION, chapters: canonicalChapters },
+    error: '',
+  }
+}
+
+/**
+ * Pure mechanical preflight.  No state is written here, so a checker error
+ * cannot consume a repair round.  The caller records the result only after
+ * the checker completes successfully.
+ */
+export function reserveMechanicalAttempt(state, { chapterNum, draftHash, authorConfirmed = false } = {}) {
+  const base = validateRetryPolicy(state)
+  if (!base.ok) return { ok: false, state: null, error: base.error }
+  if (!validChapterNum(chapterNum)) return { ok: false, state: null, error: '章号必须是正整数' }
+  if (!HASH_RE.test(draftHash || '')) return { ok: false, state: null, error: '草稿哈希无效' }
+
+  const chapter = getChapter(base.state, chapterNum)
+  const attempts = chapter.mechanical.attempts
+  const existing = attempts.find((item) => item.draftHash === draftHash)
+  const used = countAutomaticMechanical(attempts)
+  const remaining = MAX_MECHANICAL_AUTO_REPAIRS - used
+
+  if (existing) {
+    return {
+      ok: true,
+      allowed: true,
+      idempotent: true,
+      route: existing.route,
+      state: base.state,
+      budget: mechanicalBudget(attempts),
+      action: 'repeat-same-draft',
+      error: '',
+    }
+  }
+
+  if (!attempts.length) {
+    return {
+      ok: true,
+      allowed: true,
+      idempotent: false,
+      route: 'initial',
+      state: base.state,
+      budget: { used, remaining, blocked: false },
+      action: 'run-initial-check',
+      error: '',
+    }
+  }
+
+  // An author-confirmed path is explicit and never replenishes automatic
+  // budget.  It is checked before the automatic limit so it remains usable
+  // after the automatic path is exhausted.
+  if (authorConfirmed === true) {
+    return {
+      ok: true,
+      allowed: true,
+      idempotent: false,
+      route: 'author',
+      state: base.state,
+      budget: { used, remaining, blocked: false },
+      action: 'run-author-confirmed-check',
+      error: '',
+    }
+  }
+
+  const latest = attempts.at(-1)
+  if (latest?.result !== 'fail') {
+    return {
+      ok: true,
+      allowed: false,
+      idempotent: false,
+      route: null,
+      state: base.state,
+      budget: { used, remaining, blocked: true },
+      action: 'request-author-confirmation',
+      error: '本章已有通过机检的草稿;新的不同草稿须由作者明确确认后再检。',
+    }
+  }
+  if (used >= MAX_MECHANICAL_AUTO_REPAIRS) {
+    return {
+      ok: true,
+      allowed: false,
+      idempotent: false,
+      route: null,
+      state: base.state,
+      budget: { used, remaining: 0, blocked: true },
+      action: 'request-author-confirmation',
+      error: '本章机检自动修复次数已用完,请作者确认后继续。',
+    }
+  }
+  return {
+    ok: true,
+    allowed: true,
+    idempotent: false,
+    route: 'automatic',
+    state: base.state,
+    budget: { used, remaining, blocked: false },
+    action: 'run-automatic-repair-check',
+    error: '',
+  }
+}
+
+/** Record a completed mechanical check in memory; caller writes atomically. */
+export function recordMechanicalAttempt(
+  state,
+  { chapterNum, draftHash, pass, route = 'automatic' } = {}
+) {
+  const base = validateRetryPolicy(state)
+  if (!base.ok) return { ok: false, state: null, changed: false, error: base.error }
+  if (!validChapterNum(chapterNum)) return { ok: false, state: null, changed: false, error: '章号必须是正整数' }
+  if (!HASH_RE.test(draftHash || '')) return { ok: false, state: null, changed: false, error: '草稿哈希无效' }
+  if (!MECHANICAL_ROUTES.has(route)) return { ok: false, state: null, changed: false, error: '机检路径无效' }
+  if (typeof pass !== 'boolean') return { ok: false, state: null, changed: false, error: '机检结果无效' }
+
+  const next = cloneState(base.state)
+  const chapter = getChapter(next, chapterNum)
+  const attempts = chapter.mechanical.attempts
+  const existing = attempts.find((item) => item.draftHash === draftHash)
+  if (existing) {
+    // Same hash is idempotent.  Keep the original route and only update the
+    // observed result, avoiding a second budget charge.
+    if (existing.result === (pass ? 'pass' : 'fail')) {
+      return { ok: true, state: next, changed: false, attempt: existing, error: '' }
+    }
+    existing.result = pass ? 'pass' : 'fail'
+    return { ok: true, state: next, changed: true, attempt: existing, error: '' }
+  }
+  if (route === 'initial' && attempts.length) {
+    return { ok: false, state: null, changed: false, error: '机检初次记录已存在' }
+  }
+  if (route === 'automatic' && countAutomaticMechanical(attempts) >= MAX_MECHANICAL_AUTO_REPAIRS) {
+    return { ok: false, state: null, changed: false, error: '机检自动修复次数已用完' }
+  }
+  const attempt = { draftHash, route, result: pass ? 'pass' : 'fail' }
+  attempts.push(attempt)
+  return { ok: true, state: next, changed: true, attempt, error: '' }
+}
+
+/**
+ * Reserve one two-review round without taking a lock or writing.  This is the
+ * integration API for review-input/runReviews callers that already hold the
+ * shared work-state lock.
+ */
+export async function reserveReviewAttempt(
+  repoPath,
+  { chapterNum, reviewInputToken, mode = 'pending', authorApproved = false } = {}
+) {
+  const loaded = await readRetryPolicy(repoPath)
+  if (!loaded.ok) return { ok: false, state: null, file: null, changed: false, error: loaded.error }
+  const result = reserveReviewAttemptInState(loaded.state, {
+    chapterNum,
+    reviewInputToken,
+    mode,
+    authorApproved,
+  })
+  if (!result.ok) return { ...result, file: null, changed: false }
+  return {
+    ...result,
+    file: result.changed ? retryPolicyFile(result.state) : null,
+    exists: loaded.exists,
+  }
+}
+
+/** Pure counterpart useful when the caller has already loaded the state. */
+export function reserveReviewAttemptInState(
+  state,
+  { chapterNum, reviewInputToken, mode = 'pending', authorApproved = false } = {}
+) {
+  const base = validateRetryPolicy(state)
+  if (!base.ok) return { ok: false, state: null, file: null, changed: false, error: base.error }
+  if (!validChapterNum(chapterNum)) return { ok: false, state: null, file: null, changed: false, error: '章号必须是正整数' }
+  if (!HASH_RE.test(reviewInputToken || '')) {
+    return { ok: false, state: null, file: null, changed: false, error: '审稿输入令牌无效' }
+  }
+  if (!REVIEW_MODES.has(mode)) return { ok: false, state: null, file: null, changed: false, error: '审稿模式无效' }
+
+  const chapter = getChapter(base.state, chapterNum)
+  const attempts = chapter.review.attempts
+  const existing = attempts.at(-1)
+  const used = countAutomaticReviews(attempts)
+  const remaining = MAX_AUTOMATIC_REVIEW_ATTEMPTS - used
+  // Only re-fetching the currently issued round is idempotent.  Once that
+  // round is saved, issuing even the same deterministic token is a real
+  // re-review and consumes another round.
+  if (existing?.reviewInputToken === reviewInputToken && existing.status === 'issued') {
+    if (existing.mode !== 'pending' && mode !== 'pending' && existing.mode !== mode) {
+      return {
+        ok: false,
+        state: null,
+        file: null,
+        changed: false,
+        error: '同一审稿输入令牌不能切换审稿模式',
+      }
+    }
+    if (existing.mode === 'pending' && mode !== 'pending') {
+      const next = cloneState(base.state)
+      const attempt = getChapter(next, chapterNum).review.attempts.at(-1)
+      attempt.mode = mode
+      return {
+        ok: true,
+        allowed: true,
+        idempotent: true,
+        state: next,
+        changed: true,
+        attempt,
+        used,
+        remaining,
+        blocked: false,
+        action: 'repeat-same-review-input',
+        error: '',
+      }
+    }
+    return {
+      ok: true,
+      allowed: true,
+      idempotent: true,
+      state: base.state,
+      changed: false,
+      attempt: existing,
+      used,
+      remaining,
+      blocked: false,
+      action: 'repeat-same-review-input',
+      error: '',
+    }
+  }
+
+  if (!authorApproved && used >= MAX_AUTOMATIC_REVIEW_ATTEMPTS) {
+    return {
+      ok: false,
+      allowed: false,
+      idempotent: false,
+      state: base.state,
+      changed: false,
+      attempt: null,
+      used,
+      remaining: 0,
+      blocked: true,
+      action: 'request-author-confirmation',
+      error: '本章两审自动轮次已用完,请作者明确批准后再重审。',
+    }
+  }
+
+  const route = authorApproved ? 'author' : 'automatic'
+  const next = cloneState(base.state)
+  const nextAttempts = getChapter(next, chapterNum).review.attempts
+  const attempt = { reviewInputToken, mode, route, status: 'issued' }
+  nextAttempts.push(attempt)
+  const nextUsed = used + (route === 'automatic' ? 1 : 0)
+  return {
+    ok: true,
+    allowed: true,
+    idempotent: false,
+    state: next,
+    changed: true,
+    attempt,
+    used: nextUsed,
+    remaining: MAX_AUTOMATIC_REVIEW_ATTEMPTS - nextUsed,
+    blocked: false,
+    action: route === 'author' ? 'reserve-author-approved-review' : 'reserve-automatic-review',
+    error: '',
+  }
+}
+
+/** Mark a previously reserved review token as saved; no lock or write. */
+export async function markReviewAttemptSaved(
+  repoPath,
+  { chapterNum, reviewInputToken, mode = 'pending' } = {}
+) {
+  const loaded = await readRetryPolicy(repoPath)
+  if (!loaded.ok) return { ok: false, state: null, file: null, changed: false, error: loaded.error }
+  const result = markReviewAttemptSavedInState(loaded.state, { chapterNum, reviewInputToken, mode })
+  if (!result.ok) return { ...result, file: null, changed: false }
+  return { ...result, file: result.changed ? retryPolicyFile(result.state) : null, exists: loaded.exists }
+}
+
+export function markReviewAttemptSavedInState(
+  state,
+  { chapterNum, reviewInputToken, mode = 'pending' } = {}
+) {
+  const base = validateRetryPolicy(state)
+  if (!base.ok) return { ok: false, state: null, file: null, changed: false, error: base.error }
+  if (!validChapterNum(chapterNum)) return { ok: false, state: null, file: null, changed: false, error: '章号必须是正整数' }
+  if (!HASH_RE.test(reviewInputToken || '')) {
+    return { ok: false, state: null, file: null, changed: false, error: '审稿输入令牌无效' }
+  }
+  if (!REVIEW_MODES.has(mode)) {
+    return { ok: false, state: null, file: null, changed: false, error: '审稿模式无效' }
+  }
+  const existing = getChapter(base.state, chapterNum).review.attempts.at(-1)
+  if (!existing || existing.reviewInputToken !== reviewInputToken) {
+    // `missing` lets callers separate "record absent" (save proceeds with a
+    // warning; budget was already reset by whatever removed the record) from
+    // corruption, which stays fail-closed.
+    return { ok: false, missing: true, state: null, file: null, changed: false, error: '未找到对应的审稿轮次,拒绝补记' }
+  }
+  if (existing.status === 'saved') {
+    return { ok: true, state: base.state, changed: false, attempt: existing, error: '' }
+  }
+  if (existing.mode === 'pending' && mode === 'pending') {
+    return { ok: false, state: null, file: null, changed: false, error: '保存审稿轮次时必须补全 complete 或 degraded 模式' }
+  }
+  if (existing.mode !== 'pending' && mode !== 'pending' && existing.mode !== mode) {
+    return { ok: false, state: null, file: null, changed: false, error: '保存的审稿模式与发行轮次不一致' }
+  }
+  const next = cloneState(base.state)
+  const attempt = getChapter(next, chapterNum).review.attempts.at(-1)
+  if (attempt.mode === 'pending') attempt.mode = mode
+  attempt.status = 'saved'
+  return { ok: true, state: next, changed: true, attempt, error: '' }
+}
+
+/** Return a file plan that removes retry records for the given chapters. */
+export async function clearRetryPolicyChapters(repoPath, chapterNumbers) {
+  const loaded = await readRetryPolicy(repoPath)
+  if (!loaded.ok) return { ok: false, state: null, file: null, changed: false, error: loaded.error }
+  const result = clearRetryPolicyChaptersInState(loaded.state, chapterNumbers)
+  if (!result.ok) return { ...result, file: null }
+  return { ...result, file: result.changed ? retryPolicyFile(result.state) : null, exists: loaded.exists }
+}
+
+export function clearRetryPolicyChaptersInState(state, chapterNumbers) {
+  const base = validateRetryPolicy(state)
+  if (!base.ok) return { ok: false, state: null, file: null, changed: false, error: base.error }
+  if (!Array.isArray(chapterNumbers)) {
+    return { ok: false, state: null, file: null, changed: false, error: '待清理章号必须是数组' }
+  }
+  const numbers = [...new Set(chapterNumbers)]
+  if (numbers.some((n) => !validChapterNum(n))) {
+    return { ok: false, state: null, file: null, changed: false, error: '待清理章号必须是正整数' }
+  }
+  const next = cloneState(base.state)
+  let changed = false
+  for (const chapterNum of numbers) {
+    const key = String(chapterNum)
+    if (Object.prototype.hasOwnProperty.call(next.chapters, key)) {
+      delete next.chapters[key]
+      changed = true
+    }
+  }
+  return { ok: true, state: next, changed, error: '' }
+}
+
+/** Compute the public mechanical budget fields for a chapter's current state. */
+export function mechanicalBudgetForChapter(state, chapterNum) {
+  const checked = validateRetryPolicy(state)
+  if (!checked.ok || !validChapterNum(chapterNum)) return null
+  const attempts = getChapter(checked.state, chapterNum).mechanical.attempts
+  return mechanicalBudget(attempts)
+}
+
+function mechanicalBudget(attempts) {
+  const used = countAutomaticMechanical(attempts)
+  const latest = attempts.at(-1)
+  return {
+    used,
+    remaining: MAX_MECHANICAL_AUTO_REPAIRS - used,
+    blocked: latest?.result === 'fail' && used >= MAX_MECHANICAL_AUTO_REPAIRS,
+  }
+}
+
+function validateChapterRecord(value, key) {
+  if (!isPlainObject(value) || exactKeys(value, ['mechanical', 'review']) === false) {
+    return { ok: false, state: null, error: `第 ${key} 章记录字段不完整` }
+  }
+  const mechanical = validateMechanicalRecord(value.mechanical, key)
+  if (!mechanical.ok) return mechanical
+  const review = validateReviewRecord(value.review, key)
+  if (!review.ok) return review
+  return {
+    ok: true,
+    chapter: { mechanical: mechanical.record, review: review.record },
+    error: '',
+  }
+}
+
+function validateMechanicalRecord(value, key) {
+  if (!isPlainObject(value) || exactKeys(value, ['attempts']) === false || !Array.isArray(value.attempts)) {
+    return { ok: false, state: null, error: `第 ${key} 章机检记录无效` }
+  }
+  const attempts = []
+  const hashes = new Set()
+  let initialCount = 0
+  let automaticCount = 0
+  for (const item of value.attempts) {
+    if (!isPlainObject(item) || exactKeys(item, ['draftHash', 'route', 'result']) === false) {
+      return { ok: false, state: null, error: `第 ${key} 章机检尝试字段无效` }
+    }
+    if (!HASH_RE.test(item.draftHash || '') || !MECHANICAL_ROUTES.has(item.route) || !MECHANICAL_RESULTS.has(item.result)) {
+      return { ok: false, state: null, error: `第 ${key} 章机检尝试值无效` }
+    }
+    if (hashes.has(item.draftHash)) return { ok: false, state: null, error: `第 ${key} 章机检哈希重复` }
+    hashes.add(item.draftHash)
+    if (item.route === 'initial') initialCount++
+    if (item.route === 'automatic') automaticCount++
+    attempts.push({ draftHash: item.draftHash, route: item.route, result: item.result })
+  }
+  if (initialCount > 1) return { ok: false, state: null, error: `第 ${key} 章机检初次记录重复` }
+  if (automaticCount > MAX_MECHANICAL_AUTO_REPAIRS) {
+    return { ok: false, state: null, error: `第 ${key} 章机检自动修复次数超限` }
+  }
+  if (attempts.length && attempts[0].route !== 'initial') {
+    return { ok: false, state: null, error: `第 ${key} 章机检缺少初次记录` }
+  }
+  return { ok: true, record: { attempts }, error: '' }
+}
+
+function validateReviewRecord(value, key) {
+  if (!isPlainObject(value) || exactKeys(value, ['attempts']) === false || !Array.isArray(value.attempts)) {
+    return { ok: false, state: null, error: `第 ${key} 章两审记录无效` }
+  }
+  const attempts = []
+  let automaticCount = 0
+  for (const item of value.attempts) {
+    if (!isPlainObject(item) || exactKeys(item, ['reviewInputToken', 'mode', 'route', 'status']) === false) {
+      return { ok: false, state: null, error: `第 ${key} 章两审尝试字段无效` }
+    }
+    if (!HASH_RE.test(item.reviewInputToken || '') || !REVIEW_MODES.has(item.mode) || !REVIEW_ROUTES.has(item.route) || !REVIEW_STATUSES.has(item.status)) {
+      return { ok: false, state: null, error: `第 ${key} 章两审尝试值无效` }
+    }
+    if (item.route === 'automatic') automaticCount++
+    attempts.push({
+      reviewInputToken: item.reviewInputToken,
+      mode: item.mode,
+      route: item.route,
+      status: item.status,
+    })
+  }
+  if (automaticCount > MAX_AUTOMATIC_REVIEW_ATTEMPTS) {
+    return { ok: false, state: null, error: `第 ${key} 章两审自动轮次超限` }
+  }
+  return { ok: true, record: { attempts }, error: '' }
+}
+
+function getChapter(state, chapterNum) {
+  const key = String(chapterNum)
+  if (!state.chapters[key]) {
+    state.chapters[key] = { mechanical: { attempts: [] }, review: { attempts: [] } }
+  }
+  return state.chapters[key]
+}
+
+function cloneState(state) {
+  return JSON.parse(JSON.stringify(state))
+}
+
+function countAutomaticMechanical(attempts) {
+  return attempts.reduce((count, item) => count + (item.route === 'automatic' ? 1 : 0), 0)
+}
+
+function countAutomaticReviews(attempts) {
+  return attempts.reduce((count, item) => count + (item.route === 'automatic' ? 1 : 0), 0)
+}
+
+function validChapterNum(value) {
+  return Number.isInteger(value) && value > 0
+}
+
+function isCanonicalChapterKey(key) {
+  return /^\d+$/.test(key) && Number(key) > 0 && String(Number(key)) === key
+}
+
+function compareChapterKeys(left, right) {
+  return Number(left) - Number(right)
+}
+
+function isPlainObject(value) {
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return false
+  const proto = Object.getPrototypeOf(value)
+  return proto === Object.prototype || proto === null
+}
+
+function exactKeys(value, expected) {
+  const actual = Object.keys(value).sort()
+  const wanted = [...expected].sort()
+  return actual.length === wanted.length && actual.every((key, index) => key === wanted[index])
+}
+
+export { HASH_RE }

+ 37 - 4
v7/src/review/index.js

@@ -25,6 +25,7 @@ import {
   reviewInputFile,
 } from './input-binding.js'
 import { acquireContractMutationLock } from '../staging/contract-invalidation.js'
+import { markReviewAttemptSaved, reserveReviewAttempt } from '../retry-policy/index.js'
 
 const 兼容声明 = '本次使用兼容模式(单上下文顺序审稿),审稿隔离度低于完整两审模式。'
 const 完整声明 = '完整两审模式(事实审查/编辑审各自独立上下文)。'
@@ -299,7 +300,7 @@ export function mergeReviews(
  * @param {{repoPath}} ctx
  * @param {{chapterNum, merged, draft, 待确认新专名?: string[], 章摘要?: string}} args
  */
-export async function persistReviewReport(ctx, { chapterNum, merged, draft, 待确认新专名 = [], 章摘要 = '', raw = null }) {
+export async function persistReviewReport(ctx, { chapterNum, merged, draft, 待确认新专名 = [], 章摘要 = '', raw = null, extraFiles = [] }) {
   const { repoPath } = ctx
 
   const issueLines = merged.issues.length
@@ -343,6 +344,7 @@ export async function persistReviewReport(ctx, { chapterNum, merged, draft, 待
     files.push({ path: path.join('工作区', '评审报告', '编辑审.raw.json'), content: JSON.stringify(raw.editorial ?? {}, null, 2) })
   }
   files.push({ path: path.join('工作区', '审稿.md'), content: md })
+  files.push(...extraFiles)
 
   await writeAtomicBatch(repoPath, files)
 
@@ -407,6 +409,23 @@ export async function saveReviews(
       { factCheck: vFact.report, editorial: vEdit.report },
       { mode, chapterNum, reviewInputToken }
     )
+    // 记账在报告落盘前核对、与报告同一原子批写入:损坏 fail closed;
+    // 记录缺失(作者删过预算文件)只降级为 warning——审稿结果本身是合法产物。
+    const marked = await markReviewAttemptSaved(ctx.repoPath, { chapterNum, reviewInputToken, mode })
+    const warnings = []
+    let budgetFiles = []
+    if (marked.ok) {
+      if (marked.file) budgetFiles = [marked.file]
+      // 已 saved 轮次的再次保存合法(作者裁决重提),但必须可见——否则预算拒绝
+      // 后拿留存 sidecar 覆盖保存会毫无痕迹。
+      if (!marked.changed && marked.attempt?.status === 'saved') {
+        warnings.push('本轮审稿结果此前已保存,本次按同一轮次覆盖,不计新轮。')
+      }
+    } else if (marked.missing) {
+      warnings.push('重试预算记录中找不到与本次审稿输入令牌对应的轮次,审稿结果已照常保存;需要重审时请先重新运行 review-input。')
+    } else {
+      return { ok: false, errors: [marked.error] }
+    }
     const saved = await persistReviewReport(ctx, {
       chapterNum,
       merged,
@@ -414,8 +433,9 @@ export async function saveReviews(
       待确认新专名,
       章摘要,
       raw: { factCheck: rawFact, editorial: rawEdit },
+      extraFiles: budgetFiles,
     })
-    return { ok: true, merged, 审稿路径: saved.审稿路径, errors: [] }
+    return { ok: true, merged, 审稿路径: saved.审稿路径, warnings, errors: [] }
   } finally {
     await lock.release()
   }
@@ -426,19 +446,32 @@ export async function saveReviews(
  * @param {{repoPath, cache}} ctx
  * @param {{chapterNum, draftPath, mode, reviewers, 待确认新专名?, 章摘要?}} args
  */
-export async function runReviews(ctx, { chapterNum, draftPath, mode = 'complete', reviewers, 待确认新专名, 章摘要 }) {
+export async function runReviews(ctx, { chapterNum, draftPath, mode = 'complete', reviewers, 待确认新专名, 章摘要, authorApproved = false }) {
   const initialLock = await acquireContractMutationLock(ctx.repoPath, '组装审稿输入')
   if (!initialLock.ok) return { ok: false, errors: [initialLock.error] }
   let inp
+  let reserved = null
   try {
     inp = await assembleReviewInput(ctx, { chapterNum, draftPath })
     if (inp.ok) {
-      await writeAtomicBatch(ctx.repoPath, [reviewInputFile(inp.input)])
+      // 调用模型前在锁内预约两审轮次;拒绝时不写审稿输入,直接超限转作者。
+      reserved = await reserveReviewAttempt(ctx.repoPath, {
+        chapterNum,
+        reviewInputToken: inp.input.审稿输入令牌,
+        mode,
+        authorApproved,
+      })
+      if (reserved.ok) {
+        const files = [reviewInputFile(inp.input)]
+        if (reserved.file) files.push(reserved.file)
+        await writeAtomicBatch(ctx.repoPath, files)
+      }
     }
   } finally {
     await initialLock.release()
   }
   if (!inp.ok) return { ok: false, errors: [inp.error] }
+  if (!reserved.ok) return { ok: false, errors: [reserved.error] }
 
   let rawFact
   let rawEdit

+ 4 - 3
v7/src/session/index.js

@@ -4,7 +4,8 @@ import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
 
 /**
  * SessionStart 注入与书单登记(story-repo-spec §2.0)。
- * 有 hook 宿主(Claude Code)启动调本层;无 hook 宿主由状态机入口调同一函数,行为等价。
+ * Claude Code 的 hook 与无 hook 宿主生成壳都调用 session-context;next 另走状态机序 2
+ * 做事后手改检测,不能把两者描述成 PreToolUse 写前拦截。
  * 读侧 + 扫描重建自愈(M4)与写侧(登记/换书/最后打开,M5)同模块,books.jsonl 格式单源。
  */
 
@@ -152,8 +153,8 @@ export async function touchLastOpened(workdir, 目录) {
 }
 
 /**
- * 组装 SessionStart 注入文本(当前在写哪本/共几本/全书近况入口)。
- * 登记缺失或为空 → 扫描重建。两个宿主入口调本函数 → 注入逐字一致。
+ * 组装会话上下文文本(当前在写哪本/共几本/全书近况入口)。
+ * 登记缺失或为空 → 扫描重建。SessionStart 与生成壳显式入口调本函数 → 文本逐字一致。
  */
 export async function assembleSessionContext(workdir) {
   const { books, rebuilt, needsAuthorPick } = await loadBooks(workdir)

+ 2 - 0
v7/src/staging/contract-invalidation.js

@@ -8,6 +8,7 @@ import {
   parseChapterKnowledgeSnapshot,
 } from '../knowledge/chapter.js'
 import { readCurrentReviewInput } from '../review/input-binding.js'
+import { RETRY_POLICY_PATH } from '../retry-policy/index.js'
 
 export const CONTRACT_INVALIDATION_MARKER = path.join('工作区', '契约更新待重备料.md')
 export const CONTRACT_INVALIDATION_GUARD = path.join('工作区', '契约失效.json')
@@ -353,6 +354,7 @@ export async function overlapsMachineWorkspaceState(repoPath, name) {
     path.join(repoPath, CONTRACT_INVALIDATION_MARKER),
     path.join(repoPath, CONTRACT_UPDATE_LOCK),
     path.join(repoPath, CHAPTER_KNOWLEDGE_SNAPSHOT_PATH),
+    path.join(repoPath, RETRY_POLICY_PATH),
   ]
   for (const protectedPath of protectedPaths) {
     if (await filesystemPathsOverlap(target, protectedPath)) return true

+ 18 - 13
v7/src/staging/index.js

@@ -17,6 +17,7 @@ import {
   SENTENCE_VARIANCE_TOLERANCE,
 } from '../style-stats/index.js'
 import { runHealthCheck } from '../health-check/index.js'
+import { readCurrentBaselineFingerprint } from '../health-check/baseline.js'
 import { renderBookStatus } from '../prep/book-status.js'
 import { applyReviewOutcome } from '../review/outcome.js'
 import {
@@ -53,6 +54,7 @@ import {
   verifyPendingCommitProofFile,
   workspaceRemovalIsContained,
 } from './contract-invalidation.js'
+import { clearRetryPolicyChapters } from '../retry-policy/index.js'
 
 /**
  * staging:待定稿批次(自动模式,spec §8.1)。批次真源 = 工作区/待定稿/ 下的文件,
@@ -610,7 +612,7 @@ export async function judgeStop(ctx, batch, opts = {}) {
     reasons.push(`连续 ${连续无变动} 章无条目变动(上限 ${无变动上限})——剧情可能在原地踏步`)
   }
 
-  const baseline = await readBaselineFingerprint(cache)
+  const baseline = await readCurrentBaselineFingerprint(cache, config.ok ? cfg : null)
   const q = judgeBatchQuality(staged, baseline, cfg)
   if (!q.过线) reasons.push(...q.原因)
 
@@ -640,17 +642,6 @@ export async function judgeStop(ctx, batch, opts = {}) {
   return { stop: reasons.length > 0, reasons }
 }
 
-async function readBaselineFingerprint(cache) {
-  try {
-    const rows = await cache.query(
-      'SELECT avg_sentence_length, sentence_length_variance FROM fingerprints WHERE is_baseline = 1 ORDER BY chapter_range_end DESC LIMIT 1'
-    )
-    return rows[0] || null
-  } catch {
-    return null
-  }
-}
-
 /**
  * 批次质检("体检不过线"的批次落点,纯函数零 IO):弱钩尾 / 缺书内时间 / 句式 vs 基线。
  * @param {Array<{章号, frontMatter, body}>} staged 升序 staged 章
@@ -1504,5 +1495,19 @@ async function discardBatchLocked(repoPath) {
       }
     }
   }
-  return { ok: true, 章数: batch.章列表.length, error: '' }
+
+  // 整批丢弃即放弃这些章的当前稿:清重试预算(决策 2026-07-23),否则重写同一
+  // 章号会顶着旧计数直接被挡。失败只记 warning,批次本体已经丢弃成功。
+  const warnings = []
+  try {
+    const cleared = await clearRetryPolicyChapters(repoPath, [...discardedChapters])
+    if (!cleared.ok) {
+      warnings.push(`批次已丢弃,但重试预算清理失败:${cleared.error};重写这些章前请手动删除 工作区/重试预算.json 中对应章记录。`)
+    } else if (cleared.file) {
+      await writeAtomicBatch(repoPath, [cleared.file])
+    }
+  } catch (err) {
+    warnings.push(`批次已丢弃,但重试预算清理失败(${err.message});重写这些章前请手动删除 工作区/重试预算.json 中对应章记录。`)
+  }
+  return { ok: true, 章数: batch.章列表.length, warnings, error: '' }
 }

+ 31 - 2
v7/src/storage/parsers/book-config.js

@@ -1,5 +1,8 @@
 import { parseYAML } from './yaml-safe.js'
 
+// 文体基线默认区间的唯一真源;建书写盘校验(knowledge/contract.js)与解析必须同判。
+export const 文体基线默认区间 = Object.freeze({ 文体基线起: 1, 文体基线止: 30 })
+
 /**
  * 解析 book.yaml 配置文件(平铺字段)。
  * @param {string} yamlString - book.yaml 文件内容
@@ -25,8 +28,8 @@ export function parseBookConfig(yamlString) {
     流派: [],
     每章目标字数: 3000,
     卷规模: 40,
-    文体基线起: 1,
-    文体基线止: 30,
+    文体基线起: 文体基线默认区间.文体基线起,
+    文体基线止: 文体基线默认区间.文体基线止,
     伏笔悬了太久章数: 10,
     悬念悬了太久章数: 10,
     感情线悬了太久章数: 30,
@@ -44,6 +47,32 @@ export function parseBookConfig(yamlString) {
     if (mergedData[field] == null) mergedData[field] = []
   }
 
+  const baselineErrors = []
+  const baselineStart = mergedData.文体基线起
+  const baselineEnd = mergedData.文体基线止
+  if (!Number.isInteger(baselineStart) || baselineStart < 1) {
+    baselineErrors.push('「文体基线起」必须是正整数')
+  }
+  if (!Number.isInteger(baselineEnd) || baselineEnd < 1) {
+    baselineErrors.push('「文体基线止」必须是正整数')
+  }
+  if (
+    Number.isInteger(baselineStart) &&
+    baselineStart > 0 &&
+    Number.isInteger(baselineEnd) &&
+    baselineEnd > 0 &&
+    baselineStart > baselineEnd
+  ) {
+    baselineErrors.push('「文体基线起」不能大于「文体基线止」')
+  }
+  if (baselineErrors.length) {
+    return {
+      ok: false,
+      data: null,
+      error: `book.yaml 配置无效:${baselineErrors.join(';')}`,
+    }
+  }
+
   return {
     ok: true,
     data: mergedData,

+ 1 - 1
v7/test/commands/books-commands.test.js

@@ -70,7 +70,7 @@ test('switch-book:换书改当前标记;缺参数人话报错', async () => {
   }
 })
 
-test('session-context:输出与 assembleSessionContext 逐字一致(两宿主入口等价),并刷新最后打开', async () => {
+test('session-context:输出与 assembleSessionContext 逐字一致(hook 注入与 SKILL 显式调用同源),并刷新最后打开', async () => {
   const { root, ctx, cleanup } = await tmpWorkdir([
     { 书名: '星海', 目录: '星海', 当前: true, 最后打开: '2000-01-01' },
   ])

+ 128 - 0
v7/test/commands/mechanical-check.test.js

@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
 import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { run } from '../../src/commands/mechanical-check.js'
+import { RETRY_POLICY_PATH } from '../../src/retry-policy/index.js'
 import { tempBookCtx } from './_helper.js'
 
 test('mechanical-check 输出 pass/issues/candidates JSON', async () => {
@@ -26,6 +27,120 @@ test('mechanical-check 输出 pass/issues/candidates JSON', async () => {
     assert.equal(typeof out.pass, 'boolean')
     assert.ok(Array.isArray(out.issues))
     assert.ok(Array.isArray(out.candidates))
+    assert.equal(out.used, 0)
+    assert.equal(out.remaining, 2)
+    assert.equal(out.blocked, false)
+    assert.equal(out.executed, true)
+    assert.equal(typeof out.action, 'string')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('mechanical-check 两个不同修订稿耗尽自动预算,第三个先拒绝,作者确认可继续且不重置', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  const draftPath = path.join(ctx.repoPath, '工作区', '草稿-A.md')
+  try {
+    const outputs = []
+    for (const marker of ['初稿', '修订一', '修订二']) {
+      await fs.writeFile(draftPath, failingDraft(marker), 'utf8')
+      const result = await run(['3'], {}, ctx)
+      assert.equal(result.ok, true, result.error)
+      outputs.push(JSON.parse(result.output))
+    }
+    assert.deepEqual(outputs.map((out) => out.used), [0, 1, 2])
+    assert.deepEqual(outputs.map((out) => out.remaining), [2, 1, 0])
+    assert.equal(outputs[2].blocked, true)
+    assert.equal(outputs[2].action, 'hand-off-to-author')
+
+    const statePath = path.join(ctx.repoPath, RETRY_POLICY_PATH)
+    const beforeBlocked = await fs.readFile(statePath, 'utf8')
+    await fs.writeFile(draftPath, failingDraft('自动第三轮'), 'utf8')
+    const blockedResult = await run(['3'], {}, ctx)
+    assert.equal(blockedResult.ok, true, blockedResult.error)
+    const blocked = JSON.parse(blockedResult.output)
+    assert.equal(blocked.executed, false)
+    assert.equal(blocked.used, 2)
+    assert.equal(blocked.remaining, 0)
+    assert.equal(blocked.blocked, true)
+    assert.equal(blocked.action, 'request-author-confirmation')
+    assert.equal(await fs.readFile(statePath, 'utf8'), beforeBlocked, '拒绝前不执行也不改状态')
+
+    const authorResult = await run(['3'], { 'author-confirmed': true }, ctx)
+    assert.equal(authorResult.ok, true, authorResult.error)
+    const author = JSON.parse(authorResult.output)
+    assert.equal(author.executed, true)
+    assert.equal(author.used, 2)
+    assert.equal(author.remaining, 0)
+    const state = JSON.parse(await fs.readFile(statePath, 'utf8'))
+    assert.deepEqual(
+      state.chapters['3'].mechanical.attempts.map((item) => item.route),
+      ['initial', 'automatic', 'automatic', 'author']
+    )
+  } finally {
+    await cleanup()
+  }
+})
+
+test('mechanical-check 同一草稿内容 hash 重检幂等,不重复扣自动轮次', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    const draftPath = path.join(ctx.repoPath, '工作区', '草稿-A.md')
+    await fs.writeFile(draftPath, failingDraft('同稿'), 'utf8')
+    const first = JSON.parse((await run(['3'], {}, ctx)).output)
+    const second = JSON.parse((await run(['3'], {}, ctx)).output)
+    assert.equal(first.used, 0)
+    assert.equal(second.used, 0)
+    const state = JSON.parse(
+      await fs.readFile(path.join(ctx.repoPath, RETRY_POLICY_PATH), 'utf8')
+    )
+    assert.equal(state.chapters['3'].mechanical.attempts.length, 1)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('mechanical-check 只改 front matter 也是新草稿版本,消耗自动额度', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    const draftPath = path.join(ctx.repoPath, '工作区', '草稿-A.md')
+    const body = '林晚同稿正文。'
+    const withTitle = (title) => `---
+章号: 3
+标题: ${title}
+卷: 1
+字数: 4
+章定位: 推进
+钩子: 危机钩-强
+情绪定位: 铺垫
+---
+${body}`
+    await fs.writeFile(draftPath, withTitle('第一版'), 'utf8')
+    const first = JSON.parse((await run(['3'], {}, ctx)).output)
+    await fs.writeFile(draftPath, withTitle('第二版'), 'utf8')
+    const second = JSON.parse((await run(['3'], {}, ctx)).output)
+    assert.equal(first.used, 0, '初检免费')
+    assert.equal(second.used, 1, '正文未变、只改 front matter 也必须扣一轮自动修复')
+    const state = JSON.parse(
+      await fs.readFile(path.join(ctx.repoPath, RETRY_POLICY_PATH), 'utf8')
+    )
+    assert.deepEqual(
+      state.chapters['3'].mechanical.attempts.map((item) => item.route),
+      ['initial', 'automatic']
+    )
+  } finally {
+    await cleanup()
+  }
+})
+
+test('mechanical-check 重试预算损坏 → fail closed', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    await fs.writeFile(path.join(ctx.repoPath, '工作区', '草稿-A.md'), failingDraft('损坏'), 'utf8')
+    await fs.writeFile(path.join(ctx.repoPath, RETRY_POLICY_PATH), '{bad', 'utf8')
+    const result = await run(['3'], {}, ctx)
+    assert.equal(result.ok, false)
+    assert.match(result.error, /重试预算记录损坏.*停止自动流程/)
   } finally {
     await cleanup()
   }
@@ -40,3 +155,16 @@ test('mechanical-check 非数字章号 → ok=false', async () => {
     await cleanup()
   }
 })
+
+function failingDraft(marker) {
+  return `---
+章号: 3
+标题: ${marker}
+卷: 1
+字数: 4
+章定位: 推进
+钩子: 危机钩-强
+情绪定位: 铺垫
+---
+林晚${marker}。`
+}

+ 63 - 4
v7/test/commands/report-style-drift.test.js

@@ -1,14 +1,69 @@
 import { test } from 'node:test'
 import assert from 'node:assert/strict'
 import { run } from '../../src/commands/report-style-drift.js'
-import { fixtureCtx } from './_helper.js'
+import { fixtureCtx, repoCtx } from './_helper.js'
 
-test('report-style-drift 无指纹数据 → 友好错误(M1 边界,不做特征提取)', async () => {
+// 基线区间已写满的小书:两章定稿,book.yaml 配置区间恰为 1-2
+function 齐全区间书() {
+  const chapter = (n) =>
+    [
+      '---',
+      `章号: ${n}`,
+      `标题: 第${n}章`,
+      '卷: 1',
+      '字数: 100',
+      '章定位: 推进',
+      '钩子: 危机钩-强',
+      '情绪定位: 铺垫',
+      '---',
+      '',
+      `第${n}章的正文。`,
+      '',
+    ].join('\n')
+  return {
+    'book.yaml':
+      'spec_version: "7.0"\n书名: 漂移测试书\n类型: 玄幻\n文体基线起: 1\n文体基线止: 2\n',
+    '定稿/正文/0001-第1章.md': chapter(1),
+    '定稿/正文/0002-第2章.md': chapter(2),
+  }
+}
+
+test('report-style-drift 基线区间还没写满 → 说明区间与进度,不指向再跑体检', async () => {
   const { ctx, cleanup } = await fixtureCtx()
   try {
+    // sample-book 配置区间 1-30,只定稿到第 2 章:此时再跑体检也建不出基线
+    const r = await run([], {}, ctx)
+    assert.equal(r.ok, false)
+    assert.match(r.error, /文体基线尚未建立:配置区间为第 1-30 章/)
+    assert.match(r.error, /2\/30 章/)
+    assert.match(r.error, /区间内每章都定稿后/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('report-style-drift 区间已写满但没跑过体检 → 指向先跑体检', async () => {
+  const { ctx, cleanup } = await repoCtx(null, 齐全区间书())
+  try {
+    const r = await run([], {}, ctx)
+    assert.equal(r.ok, false)
+    assert.match(r.error, /请先运行体检/)
+    assert.doesNotMatch(r.error, /尚未建立/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('report-style-drift 基线已建立但全书仍在区间内 → 说明暂无近段可比', async () => {
+  const { ctx, cleanup } = await repoCtx(null, 齐全区间书())
+  try {
+    await ctx.cache.query(
+      "INSERT INTO fingerprints (chapter_range_start, chapter_range_end, is_baseline, avg_sentence_length, sentence_length_variance, vocabulary_richness, fingerprint_data) VALUES (1, 2, 1, 20.0, 30.0, 0.5, '{}')"
+    )
     const r = await run([], {}, ctx)
     assert.equal(r.ok, false)
-    assert.match(r.error, /指纹|体检/)
+    assert.match(r.error, /暂无可比的近段/)
+    assert.match(r.error, /第 1-2 章/)
   } finally {
     await cleanup()
   }
@@ -17,16 +72,20 @@ test('report-style-drift 无指纹数据 → 友好错误(M1 边界,不做
 test('report-style-drift 有基线+最近指纹 → 返回含句长方差在内的差异', async () => {
   const { ctx, cleanup } = await fixtureCtx()
   try {
-    // 手工插入基线 + 最近指纹,只验证对比逻辑(特征提取由体检测试覆盖)
+    // 当前配置是 1-30;额外插入章号更晚的陈旧 active 行,确认命令不按“最大历史范围”取基线。
     await ctx.cache.query(
       "INSERT INTO fingerprints (chapter_range_start, chapter_range_end, is_baseline, avg_sentence_length, sentence_length_variance, vocabulary_richness, fingerprint_data) VALUES (1, 30, 1, 20.0, 30.0, 0.5, '{}')"
     )
+    await ctx.cache.query(
+      "INSERT INTO fingerprints (chapter_range_start, chapter_range_end, is_baseline, avg_sentence_length, sentence_length_variance, vocabulary_richness, fingerprint_data) VALUES (41, 50, 1, 100.0, 100.0, 0.9, '{}')"
+    )
     await ctx.cache.query(
       "INSERT INTO fingerprints (chapter_range_start, chapter_range_end, is_baseline, avg_sentence_length, sentence_length_variance, vocabulary_richness, fingerprint_data) VALUES (31, 40, 0, 25.0, 42.5, 0.6, '{}')"
     )
     const r = await run([], {}, ctx)
     assert.equal(r.ok, true)
     const drift = JSON.parse(r.output)
+    assert.deepEqual(drift.基线章段, [1, 30])
     assert.ok(Math.abs(drift.avg_sentence_length_delta - 5.0) < 1e-9)
     assert.ok(Math.abs(drift.sentence_length_variance_delta - 12.5) < 1e-9)
   } finally {

+ 40 - 1
v7/test/finalize/finalize.test.js

@@ -44,6 +44,17 @@ test('finalizeChapter 正常定稿:落档 + git commit + 清工作区', async
   try {
     const git = createGit(ctx.repoPath)
     const before = await git.revCount()
+    await fs.writeFile(
+      path.join(ctx.repoPath, '工作区', '重试预算.json'),
+      JSON.stringify({
+        schemaVersion: 1,
+        chapters: {
+          3: { mechanical: { attempts: [{ draftHash: `sha256:${'a'.repeat(64)}`, route: 'initial', result: 'fail' }] }, review: { attempts: [] } },
+          99: { mechanical: { attempts: [{ draftHash: `sha256:${'b'.repeat(64)}`, route: 'initial', result: 'fail' }] }, review: { attempts: [] } },
+        },
+      }),
+      'utf8'
+    )
 
     const r = await finalizeChapter(ctx, payload())
     assert.equal(r.ok, true, r.error)
@@ -58,6 +69,12 @@ test('finalizeChapter 正常定稿:落档 + git commit + 清工作区', async
 
     await assert.rejects(() => fs.access(path.join(ctx.repoPath, '工作区/细纲.md')))
     await assert.rejects(() => fs.access(path.join(ctx.repoPath, CHAPTER_KNOWLEDGE_SNAPSHOT_PATH)))
+
+    const budget = JSON.parse(
+      await fs.readFile(path.join(ctx.repoPath, '工作区', '重试预算.json'), 'utf8')
+    )
+    assert.equal(budget.chapters['3'], undefined, 'commit confirmed 后清该章重试预算')
+    assert.ok(budget.chapters['99'], '其他章的重试预算保留')
   } finally {
     await cleanup()
   }
@@ -294,9 +311,22 @@ test('finalizeChapter 清理遇目录与可疑路径:仍 ok、目录被清、
     const reportDir = path.join(ctx.repoPath, '工作区', '评审报告')
     await fs.mkdir(reportDir, { recursive: true })
     await fs.writeFile(path.join(reportDir, '事实审查.md'), '通过', 'utf8')
+    // payload 借 workspaceFiles 指名删重试预算 → 必须被系统状态保护拦下,
+    // 但本章记录仍由定稿清理精确移除
+    await fs.writeFile(
+      path.join(ctx.repoPath, '工作区', '重试预算.json'),
+      JSON.stringify({
+        schemaVersion: 1,
+        chapters: {
+          3: { mechanical: { attempts: [{ draftHash: `sha256:${'c'.repeat(64)}`, route: 'initial', result: 'fail' }] }, review: { attempts: [] } },
+          88: { mechanical: { attempts: [{ draftHash: `sha256:${'d'.repeat(64)}`, route: 'initial', result: 'fail' }] }, review: { attempts: [] } },
+        },
+      }),
+      'utf8'
+    )
 
     const p = payload()
-    p.workspaceFiles = ['细纲.md', '评审报告', '工作区/审稿.md', '../定稿/正文/0001-开局.md']
+    p.workspaceFiles = ['细纲.md', '评审报告', '工作区/审稿.md', '../定稿/正文/0001-开局.md', '重试预算.json']
     const r = await finalizeChapter(ctx, p)
     assert.equal(r.ok, true, r.error)
     assert.ok(r.commitHash, '应正常入档')
@@ -308,6 +338,15 @@ test('finalizeChapter 清理遇目录与可疑路径:仍 ok、目录被清、
       (r.warnings || []).some((w) => w.includes('可疑路径')),
       `应有可疑路径告警:${JSON.stringify(r.warnings)}`
     )
+    assert.ok(
+      (r.warnings || []).some((w) => w.includes('系统状态路径') && w.includes('重试预算')),
+      `重试预算必须被机器状态保护拦下:${JSON.stringify(r.warnings)}`
+    )
+    const budget = JSON.parse(
+      await fs.readFile(path.join(ctx.repoPath, '工作区', '重试预算.json'), 'utf8')
+    )
+    assert.equal(budget.chapters['3'], undefined, '本章记录由定稿清理精确移除')
+    assert.ok(budget.chapters['88'], 'payload 删不掉其他章的重试预算')
   } finally {
     await cleanup()
   }

+ 140 - 4
v7/test/health-check/index.test.js

@@ -184,20 +184,40 @@ test('体检 单项失败不炸整体:正文文件读不到 → 三统计节
   }
 })
 
-test('体检 基线段与近段重合(全书尚在基线区间)→ 只落基线行,如实说明', async () => {
+test('体检 配置基线区间未齐 → 不写部分前缀基线,也不比较漂移', async () => {
   const files = fixtureFiles({
     bookYaml:
       'spec_version: "7.0"\n书名: 体检测试书\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n文体基线起: 1\n文体基线止: 30\n体检周期: 50\n',
   })
   const { ctx, cleanup } = await repoCtx(null, files)
+  try {
+    const r = await runHealthCheck(ctx)
+    assert.equal(r.ok, true, r.error)
+    const rows = await ctx.cache.query('SELECT * FROM fingerprints WHERE is_baseline = 1')
+    assert.equal(rows.length, 0)
+    const report = await fs.readFile(r.filePath, 'utf8')
+    assert.match(report, /文体基线尚未建立.*第 1-30 章.*5\/30 章/)
+    assert.equal(r.data.指纹, null)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('体检 完整基线恰与近段重合 → 只写配置区间的基线行', async () => {
+  const files = fixtureFiles({
+    bookYaml:
+      'spec_version: "7.0"\n书名: 体检测试书\n类型: 玄幻\n文体基线起: 1\n文体基线止: 5\n体检周期: 50\n',
+  })
+  const { ctx, cleanup } = await repoCtx(null, files)
   try {
     const r = await runHealthCheck(ctx)
     assert.equal(r.ok, true, r.error)
     const rows = await ctx.cache.query('SELECT * FROM fingerprints')
     assert.equal(rows.length, 1)
-    assert.equal(rows[0].is_baseline, 1)
-    assert.equal(rows[0].chapter_range_start, 1)
-    assert.equal(rows[0].chapter_range_end, 5)
+    assert.deepEqual(
+      [rows[0].chapter_range_start, rows[0].chapter_range_end, rows[0].is_baseline],
+      [1, 5, 1]
+    )
     const report = await fs.readFile(r.filePath, 'utf8')
     assert.match(report, /基线与近段重合,暂无漂移可比/)
     assert.equal(r.data.指纹.近段, null)
@@ -206,3 +226,119 @@ test('体检 基线段与近段重合(全书尚在基线区间)→ 只落基
     await cleanup()
   }
 })
+
+test('体检 配置切换到未齐区间 → 移除旧 active 基线,不回退历史范围', async () => {
+  const { ctx, cleanup } = await repoCtx(null, fixtureFiles())
+  try {
+    const first = await runHealthCheck(ctx)
+    assert.equal(first.ok, true, first.error)
+    assert.deepEqual(
+      (await ctx.cache.query('SELECT chapter_range_start, chapter_range_end FROM fingerprints WHERE is_baseline = 1'))
+        .map((row) => [row.chapter_range_start, row.chapter_range_end]),
+      [[1, 2]]
+    )
+
+    await fs.writeFile(
+      path.join(ctx.repoPath, 'book.yaml'),
+      'spec_version: "7.0"\n书名: 体检测试书\n类型: 玄幻\n文体基线起: 1\n文体基线止: 6\n体检周期: 3\n',
+      'utf8'
+    )
+    const second = await runHealthCheck(ctx)
+    assert.equal(second.ok, true, second.error)
+    assert.equal(
+      (await ctx.cache.query('SELECT COUNT(*) AS c FROM fingerprints WHERE is_baseline = 1'))[0].c,
+      0
+    )
+    assert.equal(second.data.指纹, null)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('体检 配置区间中间缺章 → 即使止章已定稿也不建立基线', async () => {
+  const files = fixtureFiles({
+    bookYaml:
+      'spec_version: "7.0"\n书名: 体检测试书\n类型: 玄幻\n文体基线起: 1\n文体基线止: 3\n体检周期: 3\n',
+  })
+  delete files['定稿/正文/0002-第2章.md']
+  const { ctx, cleanup } = await repoCtx(null, files)
+  try {
+    const r = await runHealthCheck(ctx)
+    assert.equal(r.ok, true, r.error)
+    assert.equal(
+      (await ctx.cache.query('SELECT COUNT(*) AS c FROM fingerprints WHERE is_baseline = 1'))[0].c,
+      0
+    )
+    const report = await fs.readFile(r.filePath, 'utf8')
+    assert.match(report, /文体基线尚未建立.*2\/3 章/)
+    assert.equal(r.data.指纹, null)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('体检 正文读不到 → 保留已建立的基线,恢复后结果与首次逐字段一致', async () => {
+  const { ctx, cleanup } = await repoCtx(null, fixtureFiles())
+  try {
+    const 首次 = await runHealthCheck(ctx)
+    assert.equal(首次.ok, true, 首次.error)
+    const q =
+      'SELECT * FROM fingerprints ORDER BY is_baseline DESC, chapter_range_start, chapter_range_end'
+    const rows1 = await ctx.cache.query(q)
+
+    // 缓存里仍有第 5 章的行,文件没了 → 语料读取失败,三统计降级
+    const 第5章 = path.join(ctx.repoPath, '定稿', '正文', '0005-第5章.md')
+    const 原文 = await fs.readFile(第5章, 'utf8')
+    await fs.rm(第5章)
+    const 降级 = await runHealthCheck(ctx)
+    assert.equal(降级.ok, true, 降级.error)
+    assert.equal(降级.data.指纹, null)
+    assert.deepEqual(
+      (
+        await ctx.cache.query(
+          'SELECT chapter_range_start, chapter_range_end FROM fingerprints WHERE is_baseline = 1'
+        )
+      ).map((row) => [row.chapter_range_start, row.chapter_range_end]),
+      [[1, 2]],
+      '语料读取失败不该连带清掉基线'
+    )
+
+    await fs.writeFile(第5章, 原文, 'utf8')
+    const 恢复 = await runHealthCheck(ctx)
+    assert.equal(恢复.ok, true, 恢复.error)
+    assert.deepStrictEqual(await ctx.cache.query(q), rows1)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('体检 配置切换到另一个齐全区间 → 旧基线行移除,新基线落新章段', async () => {
+  const { ctx, cleanup } = await repoCtx(null, fixtureFiles())
+  try {
+    const 首次 = await runHealthCheck(ctx)
+    assert.equal(首次.ok, true, 首次.error)
+    assert.deepEqual(首次.data.指纹.基线.范围, [1, 2])
+
+    await fs.writeFile(
+      path.join(ctx.repoPath, 'book.yaml'),
+      'spec_version: "7.0"\n书名: 体检测试书\n类型: 玄幻\n文体基线起: 2\n文体基线止: 4\n体检周期: 3\n',
+      'utf8'
+    )
+    const 切换后 = await runHealthCheck(ctx)
+    assert.equal(切换后.ok, true, 切换后.error)
+    assert.deepEqual(
+      (
+        await ctx.cache.query(
+          'SELECT chapter_range_start, chapter_range_end FROM fingerprints WHERE is_baseline = 1'
+        )
+      ).map((row) => [row.chapter_range_start, row.chapter_range_end]),
+      [[2, 4]]
+    )
+    assert.deepEqual(切换后.data.指纹.基线.范围, [2, 4])
+    assert.deepEqual(切换后.data.指纹.近段.范围, [3, 5])
+    const report = await fs.readFile(切换后.filePath, 'utf8')
+    assert.match(report, /基线(第 2-4 章)/)
+  } finally {
+    await cleanup()
+  }
+})

+ 25 - 2
v7/test/host-shells/generate.test.js

@@ -20,7 +20,7 @@ test('renderTemplate:agentCapable=false → 渲染兼容(降级)模式块', ()
 test('生成 claude-code 壳:hasHooks 块入、unless 块去;两审完整模式;F1 命令接线;占位符全渲染', async () => {
   const out = await generateHostShells(V7)
   const skill = out['claude-code']['skills/webnovel-writer/SKILL.md']
-  assert.match(skill, /SessionStart 已注入/)
+  assert.match(skill, /SessionStart 已注入「当前在写哪本 \/ 共几本 \/ 全书近况入口」/)
   assert.ok(!skill.includes('session-context`,向作者报'), 'hasHooks=true 应去掉 unless 块')
   assert.match(skill, /独立 subagent/)
   assert.ok(!skill.includes('兼容模式'), 'agentCapable=true 应去掉兼容模式块')
@@ -40,13 +40,36 @@ test('生成 claude-code 壳:hasHooks 块入、unless 块去;两审完整模
 test('生成 codex 壳:无 hook → unless 块入;角色输出 TOML', async () => {
   const out = await generateHostShells(V7)
   const skill = out['codex']['skills/webnovel-writer/SKILL.md']
-  assert.match(skill, /session-context/)
+  assert.match(skill, /session-context`,向作者报「当前在写哪本 \/ 共几本 \/ 全书近况入口」/)
   assert.ok(!skill.includes('SessionStart 已注入'))
   const role = out['codex']['agents/事实审查.toml']
   assert.match(role, /name = "事实审查"/)
   assert.match(role, /instructions = """/)
 })
 
+test('所有生成 SKILL 的状态机序 4 卷复盘排在序 5 体检之前', async () => {
+  const out = await generateHostShells(V7)
+  for (const [host, files] of Object.entries(out)) {
+    const skill = files['skills/webnovel-writer/SKILL.md']
+    const sequence4 = skill.indexOf('- 序4 卷复盘')
+    const sequence5 = skill.indexOf('- 序5 体检')
+    assert.ok(sequence4 >= 0, `${host} 缺序4卷复盘`)
+    assert.ok(sequence5 >= 0, `${host} 缺序5体检`)
+    assert.ok(sequence4 < sequence5, `${host} 应按状态机序号展示序4后序5`)
+  }
+})
+
+test('所有生成 SKILL 都声明 alpha hook 边界与序2事后自愈', async () => {
+  const out = await generateHostShells(V7)
+  for (const [host, files] of Object.entries(out)) {
+    const skill = files['skills/webnovel-writer/SKILL.md']
+    assert.match(skill, /next` 序2只做事后手改自愈/)
+    assert.match(skill, /不是 PreToolUse 写前拦截/)
+    assert.match(skill, /7\.0\.0-alpha 不安装 PreToolUse/)
+    assert.ok(!skill.includes('{{'), `${host} 占位符应全部渲染`)
+  }
+})
+
 test('角色占位符注入 category(来自 schema.js 单源)', async () => {
   const out = await generateHostShells(V7)
   const factRole = out['claude-code']['agents/事实审查.md']

+ 53 - 0
v7/test/installer/install.test.js

@@ -27,6 +27,18 @@ const exists = async (root, rel) => {
     return false
   }
 }
+async function listRelativeFiles(root) {
+  const files = []
+  const walk = async (dir, base = '') => {
+    for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
+      const rel = base ? `${base}/${entry.name}` : entry.name
+      if (entry.isDirectory()) await walk(path.join(dir, entry.name), rel)
+      else if (entry.isFile()) files.push(rel)
+    }
+  }
+  await walk(root)
+  return files
+}
 
 test('init:一条命令装出完整布局(AC2),vendored bin 自包含可跑', async () => {
   const { root, ctx, cleanup } = await tmpWorkdir()
@@ -52,6 +64,13 @@ test('init:一条命令装出完整布局(AC2),vendored bin 自包含可
     ]) {
       assert.ok(await exists(root, rel), `缺 ${rel}`)
     }
+    const runtimeFiles = await listRelativeFiles(path.join(root, '.webnovel'))
+    assert.ok(!runtimeFiles.some((rel) => rel.includes('story-repo-spec')), 'init 不得分发 story-repo-spec')
+    assert.ok(!runtimeFiles.some((rel) => rel.startsWith('docs/architecture/')), 'init 不得分发架构文档')
+    assert.ok(
+      runtimeFiles.filter((rel) => rel.startsWith('docs/')).every((rel) => rel.startsWith('docs/knowledge/')),
+      'init 后 .webnovel/docs 只允许知识治理文档',
+    )
     // 平台壳落位
     assert.ok(await exists(root, '.claude/skills/webnovel-writer/SKILL.md'))
     assert.ok(await exists(root, '.claude/agents/事实审查.md'))
@@ -64,6 +83,9 @@ test('init:一条命令装出完整布局(AC2),vendored bin 自包含可
     assert.ok(agents.includes('<!-- WEBNOVEL:START -->') && agents.includes('<!-- WEBNOVEL:END -->'))
     // 报告人话
     assert.ok(r.report.includes('claude-code') && r.report.includes('下一步'))
+    assert.match(r.report, /SessionStart 只注入当前书、本数与全书近况入口/)
+    assert.match(r.report, /启动时由 SKILL 显式运行 session-context/)
+    assert.doesNotMatch(r.report, /与 hook 等价/)
 
     // vendored bin 真的能跑(js-yaml 链路含在内):list-books 走 session→storage→yaml
     const out = await exec(process.execPath, [path.join(root, '.webnovel/bin/webnovel-writer.js'), 'list-books'], {
@@ -76,6 +98,37 @@ test('init:一条命令装出完整布局(AC2),vendored bin 自包含可
   }
 })
 
+test('init/update:alpha 只安装 SessionStart,并保留用户自有 PreToolUse', async () => {
+  const { root, ctx, cleanup } = await tmpWorkdir()
+  try {
+    const preToolUse = [
+      { matcher: 'Write|Edit', hooks: [{ type: 'command', command: 'node 用户自己的写前检查.mjs' }] },
+    ]
+    await fs.mkdir(path.join(root, '.claude'), { recursive: true })
+    await fs.writeFile(
+      path.join(root, '.claude', 'settings.json'),
+      JSON.stringify({ hooks: { PreToolUse: preToolUse } }, null, 2) + '\n',
+      'utf8',
+    )
+
+    const init = await installWorkdir(ctx, { hostsOverride: 'claude-code', ...NO_ENV })
+    assert.equal(init.ok, true, init.error)
+    const afterInit = JSON.parse(await read(root, '.claude/settings.json'))
+    assert.deepEqual(afterInit.hooks.PreToolUse, preToolUse)
+    assert.deepEqual(Object.keys(afterInit.hooks).sort(), ['PreToolUse', 'SessionStart'])
+    assert.equal(afterInit.hooks.SessionStart.length, 1)
+    assert.match(afterInit.hooks.SessionStart[0].hooks[0].command, /session-context$/)
+
+    const update = await installWorkdir(ctx, { hostsOverride: 'claude-code', ...NO_ENV })
+    assert.equal(update.ok, true, update.error)
+    const afterUpdate = JSON.parse(await read(root, '.claude/settings.json'))
+    assert.deepEqual(afterUpdate.hooks.PreToolUse, preToolUse)
+    assert.equal(afterUpdate.hooks.SessionStart.length, 1)
+  } finally {
+    await cleanup()
+  }
+})
+
 test('init:未检测到宿主 → 只装公约数层 + --hosts 指引(D-2)', async () => {
   const { root, ctx, cleanup } = await tmpWorkdir()
   try {

+ 20 - 0
v7/test/installer/units.test.js

@@ -89,3 +89,23 @@ test('mergeClaudeSettings:新建/保留用户配置/幂等/坏 JSON 不动', (
   assert.ok(d.error)
   assert.equal(d.content, '{坏的')
 })
+
+test('mergeClaudeSettings:只安装 SessionStart,保留用户 PreToolUse 且不跨区误判幂等', () => {
+  const userPreToolUse = [
+    { matcher: 'Write|Edit', hooks: [{ type: 'command', command: SESSION_HOOK_COMMAND }] },
+  ]
+  const user = JSON.stringify({ hooks: { PreToolUse: userPreToolUse } })
+  const merged = mergeClaudeSettings(user, SESSION_HOOK_COMMAND)
+  assert.equal(merged.changed, true)
+  const parsed = JSON.parse(merged.content)
+  assert.deepEqual(parsed.hooks.PreToolUse, userPreToolUse)
+  assert.equal(parsed.hooks.SessionStart.length, 1)
+  assert.deepEqual(parsed.hooks.SessionStart[0].hooks[0], {
+    type: 'command',
+    command: SESSION_HOOK_COMMAND,
+  })
+
+  const again = mergeClaudeSettings(merged.content, SESSION_HOOK_COMMAND)
+  assert.equal(again.changed, false)
+  assert.deepEqual(JSON.parse(again.content).hooks.PreToolUse, userPreToolUse)
+})

+ 32 - 1
v7/test/integration/cli-spawn-smoke.test.js

@@ -10,7 +10,8 @@ import { tempV6, inlineFixture } from '../migrate/_v6.js'
 
 const exec = promisify(execFile)
 const __dirname = path.dirname(fileURLToPath(import.meta.url))
-const BIN = path.join(__dirname, '../../bin/webnovel-writer.js')
+const PACKAGE_ROOT = path.join(__dirname, '../..')
+const BIN = path.join(PACKAGE_ROOT, 'bin/webnovel-writer.js')
 const fixtureRoot = path.join(__dirname, '../fixtures/sample-book')
 
 /**
@@ -26,6 +27,36 @@ test('bin --help:说明审稿输入令牌的三处原样回传约束', async (
   assert.match(r.stdout, /禁止重算/)
 })
 
+test('bin 动态导入:包路径含 #、%、空格和中文仍可加载命令', async () => {
+  const specialRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-CLI #100% 中文-'))
+  const workdir = path.join(specialRoot, '工作 目录')
+  try {
+    for (const entry of ['bin', 'src', 'node_modules']) {
+      await fs.cp(path.join(PACKAGE_ROOT, entry), path.join(specialRoot, entry), { recursive: true })
+    }
+    await fs.copyFile(path.join(PACKAGE_ROOT, 'package.json'), path.join(specialRoot, 'package.json'))
+    await fs.mkdir(path.join(workdir, '.webnovel'), { recursive: true })
+
+    const specialBin = path.join(specialRoot, 'bin', 'webnovel-writer.js')
+    const ok = await exec(process.execPath, [specialBin, 'list-books'], { cwd: workdir, encoding: 'utf8' })
+    assert.match(ok.stdout, /还没有书/)
+
+    const missing = await exec(process.execPath, [specialBin, 'not-a-command'], {
+      cwd: workdir,
+      encoding: 'utf8',
+    }).then(
+      () => null,
+      (err) => err,
+    )
+    assert.ok(missing, '不存在的命令应非零退出')
+    assert.equal(missing.code, 1)
+    assert.match(missing.stderr, /未知命令/)
+    assert.ok(!/\n\s+at /.test(missing.stderr), `错误不带栈:${missing.stderr}`)
+  } finally {
+    await fs.rm(specialRoot, { recursive: true, force: true })
+  }
+})
+
 test('bin spawn 冒烟:export 单章(书仓库直启)+ 坏参数人话退出', async () => {
   const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-spawn-book-'))
   await fs.cp(fixtureRoot, repo, { recursive: true })

+ 52 - 2
v7/test/knowledge/contract.test.js

@@ -8,6 +8,8 @@ import {
   validateWorkContract,
 } from '../../src/knowledge/contract.js'
 import { minimalCreateBookPayload, minimalWorkContract } from '../state-machine/_helper.js'
+import { parseBookConfig } from '../../src/storage/parsers/book-config.js'
+import { serializeYAML } from '../../src/storage/serializers/yaml-dialect.js'
 
 test('建书契约:作者确认、八个小节、三项差异化和五项结算全部有效才通过', () => {
   const valid = minimalCreateBookPayload()
@@ -187,8 +189,56 @@ test('契约更新:版本严格加一且必须从下一未定稿章立即生
   )
 })
 
-test('知识选择记录只保存最终采用和真实裁决,不制造评分或空反馈', () => {
-  const contract = validateWorkContract(minimalWorkContract()).data
+test('建书 book:文体基线区间必须是正整数闭区间,非法值不落 book.yaml', () => {
+  const 非法 = [
+    [{ 文体基线起: 0 }, '「文体基线起」必须是正整数'],
+    [{ 文体基线止: 0 }, '「文体基线止」必须是正整数'],
+    [{ 文体基线起: -1 }, '「文体基线起」必须是正整数'],
+    [{ 文体基线起: 1.5 }, '「文体基线起」必须是正整数'],
+    [{ 文体基线止: '1' }, '「文体基线止」必须是正整数'],
+    [{ 文体基线起: 5, 文体基线止: 3 }, '不能大于'],
+    // 只写起点:止套解析侧默认值 30,超出即区间倒置
+    [{ 文体基线起: 40 }, '不能大于'],
+  ]
+  for (const [book, 关键词] of 非法) {
+    const result = validateCreateBookPayload(minimalCreateBookPayload({ book }))
+    assert.equal(result.ok, false, JSON.stringify(book))
+    assert.ok(
+      result.errors.some((error) => error.includes('book') && error.includes(关键词)),
+      `${JSON.stringify(book)} 应报「${关键词}」,实际:${result.errors.join(';')}`
+    )
+  }
+
+  const 合法 = [{}, { 文体基线起: 1, 文体基线止: 30 }, { 文体基线起: 5 }, { 文体基线止: 10 }]
+  for (const book of 合法) {
+    const result = validateCreateBookPayload(minimalCreateBookPayload({ book }))
+    assert.equal(result.ok, true, `${JSON.stringify(book)}:${result.errors.join(';')}`)
+  }
+})
+
+test('建书 book 的文体基线校验与 book.yaml 解析侧同判(缺字段套同一默认值)', () => {
+  const cases = [
+    {},
+    { 文体基线起: 1, 文体基线止: 30 },
+    { 文体基线起: 0 },
+    { 文体基线止: 0 },
+    { 文体基线起: -1 },
+    { 文体基线起: 1.5 },
+    { 文体基线止: '1' },
+    { 文体基线起: 5, 文体基线止: 3 },
+    { 文体基线起: 40 },
+    { 文体基线起: 5 },
+    { 文体基线止: 10 },
+  ]
+  for (const book of cases) {
+    const payload = minimalCreateBookPayload({ book })
+    const 写盘前 = validateCreateBookPayload(payload)
+    const 读回 = parseBookConfig(serializeYAML(payload.book))
+    assert.equal(写盘前.ok, 读回.ok, `${JSON.stringify(book)} 两侧判定不一致`)
+  }
+})
+
+test('知识选择记录只保存最终采用和真实裁决,不制造评分或空反馈', () => {  const contract = validateWorkContract(minimalWorkContract()).data
   const content = createKnowledgeSelectionRecord({
     contract,
     selections: [{ 维度: '题材', 名称: '玄幻', 来源: '作者自定义' }],

+ 25 - 2
v7/test/mechanical-check/check.test.js

@@ -22,7 +22,8 @@ function files(draftBody, { fm, extra } = {}) {
     fm ??
     `章号: 3\n标题: 测试章\n卷: 1\n字数: ${[...draftBody.replace(/\s+/g, '')].length}\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫`
   return {
-    'book.yaml': 'spec_version: "7.0"\n书名: 测\n每章目标字数: 50\n',
+    'book.yaml':
+      'spec_version: "7.0"\n书名: 测\n每章目标字数: 50\n文体基线起: 1\n文体基线止: 2\n',
     '文风/文风铁律.md': 文风铁律,
     '定稿/设定/名册.md': 名册,
     '定稿/设定/信息差/信息差-001-x.md': 信息差,
@@ -254,7 +255,9 @@ const 基线指纹 = (avg, variance) => (ctx) =>
     [avg, variance]
   )
 
-const 目标字数 = (n) => ({ 'book.yaml': `spec_version: "7.0"\n书名: 测\n每章目标字数: ${n}\n` })
+const 目标字数 = (n) => ({
+  'book.yaml': `spec_version: "7.0"\n书名: 测\n每章目标字数: ${n}\n文体基线起: 1\n文体基线止: 2\n`,
+})
 
 test('机检 高频意象命中(体检缓存)→ 候选非阻断,pass 不受影响', async () => {
   const seed = (ctx) =>
@@ -310,6 +313,26 @@ test('机检 句式偏离边界:平均句长偏 31% 报(非阻断)', async
   }
 })
 
+test('机检 只消费当前配置的精确基线区间,忽略更晚的陈旧 active 行', async () => {
+  const body = sentencesOfLengths([13, 13, 13, 13, 13, 13, 13, 13, 13, 14])
+  const seed = async (ctx) => {
+    await 基线指纹(10, 0)(ctx)
+    await ctx.cache.run(
+      "INSERT INTO fingerprints (chapter_range_start, chapter_range_end, is_baseline, avg_sentence_length, sentence_length_variance, avg_paragraph_length, common_phrase_frequency, vocabulary_richness, fingerprint_data) VALUES (10, 20, 1, 13.1, 0, 20, '{}', 0.5, '{}')"
+    )
+  }
+  const { r, cleanup } = await runWithCache(body, { extra: 目标字数(130), seed })
+  try {
+    const candidate = r.candidates.find(
+      (item) => item.type === '句式偏离' && item.value === '平均句长'
+    )
+    assert.ok(candidate, JSON.stringify(r.candidates))
+    assert.match(candidate.description, /基线 10\.0 字/)
+  } finally {
+    await cleanup()
+  }
+})
+
 test('机检 句长方差偏离 ≥50% 报,平均句长未偏不误报', async () => {
   const body = sentencesOfLengths([10, 14]) // 均 12 与基线持平;方差 4 vs 基线 1
   const { r, cleanup } = await runWithCache(body, { extra: 目标字数(25), seed: 基线指纹(12, 1) })

+ 25 - 0
v7/test/package-metadata.test.js

@@ -0,0 +1,25 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { promises as fs } from 'node:fs'
+
+test('v7 包版本、GPL v3 元数据与随包许可证保持一致', async () => {
+  const pkg = JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf8'))
+  const lock = JSON.parse(await fs.readFile(new URL('../package-lock.json', import.meta.url), 'utf8'))
+  const [rootLicense, packageLicense] = await Promise.all([
+    fs.readFile(new URL('../../LICENSE', import.meta.url), 'utf8'),
+    fs.readFile(new URL('../LICENSE', import.meta.url), 'utf8'),
+  ])
+
+  assert.equal(pkg.version, lock.version)
+  assert.equal(pkg.version, lock.packages[''].version)
+  assert.equal(pkg.license, 'GPL-3.0-only')
+  assert.equal(lock.packages[''].license, pkg.license)
+  assert.ok(pkg.files.includes('LICENSE'))
+  assert.deepEqual(
+    pkg.files.filter((entry) => entry.startsWith('docs')),
+    ['docs/knowledge/', 'docs/migration-guide.md'],
+  )
+  assert.ok(!pkg.files.some((entry) => entry.includes('story-repo-spec')))
+  assert.ok(!pkg.files.includes('docs/'))
+  assert.equal(packageLicense, rootLicense)
+})

+ 273 - 0
v7/test/retry-policy/policy.test.js

@@ -0,0 +1,273 @@
+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 {
+  RETRY_POLICY_PATH,
+  clearRetryPolicyChapters,
+  emptyRetryPolicy,
+  markReviewAttemptSaved,
+  mechanicalBudgetForChapter,
+  readRetryPolicy,
+  recordMechanicalAttempt,
+  reserveMechanicalAttempt,
+  reserveReviewAttempt,
+  retryDraftHash,
+  retryPolicyFile,
+  validateRetryPolicy,
+} from '../../src/retry-policy/index.js'
+
+const hash = (char) => `sha256:${char.repeat(64)}`
+
+test('retry-policy:严格 schema + 确定性序列化,不写时间戳', () => {
+  let state = emptyRetryPolicy()
+  state = recordMechanicalAttempt(state, {
+    chapterNum: 10,
+    draftHash: hash('a'),
+    pass: false,
+    route: 'initial',
+  }).state
+  state = recordMechanicalAttempt(state, {
+    chapterNum: 2,
+    draftHash: hash('b'),
+    pass: true,
+    route: 'initial',
+  }).state
+
+  const file = retryPolicyFile(state)
+  assert.equal(file.path, '工作区/重试预算.json')
+  assert.ok(file.content.indexOf('"2"') < file.content.indexOf('"10"'), '章号按数值排序')
+  assert.doesNotMatch(file.content, /time|date|started|updated/i)
+  assert.deepEqual(JSON.parse(file.content), validateRetryPolicy(state).state)
+
+  const extra = JSON.parse(file.content)
+  extra.chapters['2'].mechanical.extra = true
+  const invalid = validateRetryPolicy(extra)
+  assert.equal(invalid.ok, false)
+  assert.match(invalid.error, /机检记录无效/)
+})
+
+test('retry-policy:预算键哈希整份草稿,只改 front matter 也是新版本', () => {
+  const body = '正文内容保持不变。'
+  const a = retryDraftHash(`---\n章号: 3\n---\n\n${body}`)
+  const b = retryDraftHash(`---\n章号: 3\n标题: 补上缺失字段\n---\n\n${body}`)
+  const crlf = retryDraftHash(`---\r\n章号: 3\r\n---\r\n\r\n${body}`)
+  assert.notEqual(a, b, '只改 front matter 的修复必须消耗新额度')
+  assert.equal(a, crlf, '行尾差异不算新草稿版本')
+  assert.match(a, /^sha256:[0-9a-f]{64}$/)
+})
+
+test('retry-policy:markSaved 轮次缺失带 missing 标志,区别于损坏 fail closed', async () => {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-retry-missing-'))
+  try {
+    const missing = await markReviewAttemptSaved(root, {
+      chapterNum: 1,
+      reviewInputToken: hash('f'),
+      mode: 'complete',
+    })
+    assert.equal(missing.ok, false)
+    assert.equal(missing.missing, true)
+    assert.match(missing.error, /未找到对应的审稿轮次/)
+
+    await fs.mkdir(path.join(root, '工作区'), { recursive: true })
+    await fs.writeFile(path.join(root, RETRY_POLICY_PATH), '{"schemaVersion":1,', 'utf8')
+    const corrupt = await markReviewAttemptSaved(root, {
+      chapterNum: 1,
+      reviewInputToken: hash('f'),
+      mode: 'complete',
+    })
+    assert.equal(corrupt.ok, false)
+    assert.notEqual(corrupt.missing, true, '损坏必须走 fail closed,不得当作缺失')
+  } finally {
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})
+
+test('retry-policy:损坏文件 fail closed,不静默恢复额度', async () => {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-retry-corrupt-'))
+  try {
+    await fs.mkdir(path.join(root, '工作区'), { recursive: true })
+    await fs.writeFile(path.join(root, RETRY_POLICY_PATH), '{"schemaVersion":1,', 'utf8')
+    const loaded = await readRetryPolicy(root)
+    assert.equal(loaded.ok, false)
+    assert.equal(loaded.state, null)
+    assert.match(loaded.error, /损坏.*停止自动流程/)
+  } finally {
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})
+
+test('retry-policy:机检初次免费、两个不同修订稿扣额度、同 hash 幂等、作者路径不重置', () => {
+  let state = emptyRetryPolicy()
+
+  const initial = reserveMechanicalAttempt(state, { chapterNum: 3, draftHash: hash('1') })
+  assert.equal(initial.allowed, true)
+  assert.equal(initial.route, 'initial')
+  state = recordMechanicalAttempt(state, {
+    chapterNum: 3,
+    draftHash: hash('1'),
+    pass: false,
+    route: initial.route,
+  }).state
+  assert.deepEqual(mechanicalBudgetForChapter(state, 3), { used: 0, remaining: 2, blocked: false })
+
+  const same = reserveMechanicalAttempt(state, { chapterNum: 3, draftHash: hash('1') })
+  assert.equal(same.allowed, true)
+  assert.equal(same.idempotent, true)
+  const sameRecorded = recordMechanicalAttempt(state, {
+    chapterNum: 3,
+    draftHash: hash('1'),
+    pass: false,
+    route: same.route,
+  })
+  assert.equal(sameRecorded.changed, false)
+
+  for (const char of ['2', '3']) {
+    const reserved = reserveMechanicalAttempt(state, { chapterNum: 3, draftHash: hash(char) })
+    assert.equal(reserved.allowed, true)
+    assert.equal(reserved.route, 'automatic')
+    state = recordMechanicalAttempt(state, {
+      chapterNum: 3,
+      draftHash: hash(char),
+      pass: false,
+      route: reserved.route,
+    }).state
+  }
+  assert.deepEqual(mechanicalBudgetForChapter(state, 3), { used: 2, remaining: 0, blocked: true })
+
+  const blocked = reserveMechanicalAttempt(state, { chapterNum: 3, draftHash: hash('4') })
+  assert.equal(blocked.allowed, false)
+  assert.equal(blocked.budget.blocked, true)
+  assert.equal(blocked.action, 'request-author-confirmation')
+
+  const repeatLast = reserveMechanicalAttempt(state, { chapterNum: 3, draftHash: hash('3') })
+  assert.equal(repeatLast.allowed, true, '耗尽后同 hash 仍幂等可检')
+  assert.equal(repeatLast.idempotent, true)
+
+  const author = reserveMechanicalAttempt(state, {
+    chapterNum: 3,
+    draftHash: hash('4'),
+    authorConfirmed: true,
+  })
+  assert.equal(author.allowed, true)
+  assert.equal(author.route, 'author')
+  state = recordMechanicalAttempt(state, {
+    chapterNum: 3,
+    draftHash: hash('4'),
+    pass: false,
+    route: author.route,
+  }).state
+  assert.deepEqual(mechanicalBudgetForChapter(state, 3), { used: 2, remaining: 0, blocked: true })
+})
+
+test('retry-policy:review API 无锁无落盘,issued 重取幂等,saved 后同 token 是新轮', async () => {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-retry-review-'))
+  try {
+    const first = await reserveReviewAttempt(root, {
+      chapterNum: 7,
+      reviewInputToken: hash('a'),
+      authorApproved: false,
+    })
+    assert.equal(first.ok, true)
+    assert.equal(first.changed, true)
+    assert.equal(first.used, 1)
+    assert.equal(first.remaining, 1)
+    assert.equal(first.file.path, RETRY_POLICY_PATH)
+    await assert.rejects(() => fs.access(path.join(root, RETRY_POLICY_PATH)), 'API 本身不得落盘')
+    await writePlan(root, first.file)
+
+    const duplicate = await reserveReviewAttempt(root, {
+      chapterNum: 7,
+      reviewInputToken: hash('a'),
+    })
+    assert.equal(duplicate.ok, true)
+    assert.equal(duplicate.idempotent, true)
+    assert.equal(duplicate.changed, false)
+    assert.equal(duplicate.file, null)
+    assert.equal(duplicate.used, 1)
+
+    const firstSaved = await markReviewAttemptSaved(root, {
+      chapterNum: 7,
+      reviewInputToken: hash('a'),
+      mode: 'complete',
+    })
+    assert.equal(firstSaved.ok, true)
+    assert.equal(firstSaved.attempt.status, 'saved')
+    assert.equal(firstSaved.attempt.mode, 'complete')
+    await writePlan(root, firstSaved.file)
+
+    const second = await reserveReviewAttempt(root, {
+      chapterNum: 7,
+      reviewInputToken: hash('a'),
+      mode: 'degraded',
+    })
+    assert.equal(second.ok, true)
+    assert.equal(second.idempotent, false, 'saved 后同 token 是一次新重审')
+    assert.equal(second.used, 2)
+    assert.equal(second.remaining, 0)
+    await writePlan(root, second.file)
+
+    const secondSaved = await markReviewAttemptSaved(root, {
+      chapterNum: 7,
+      reviewInputToken: hash('a'),
+      mode: 'degraded',
+    })
+    assert.equal(secondSaved.ok, true)
+    await writePlan(root, secondSaved.file)
+
+    const blocked = await reserveReviewAttempt(root, {
+      chapterNum: 7,
+      reviewInputToken: hash('a'),
+      mode: 'complete',
+    })
+    assert.equal(blocked.ok, false)
+    assert.equal(blocked.blocked, true)
+    assert.equal(blocked.action, 'request-author-confirmation')
+
+    const author = await reserveReviewAttempt(root, {
+      chapterNum: 7,
+      reviewInputToken: hash('a'),
+      mode: 'complete',
+      authorApproved: true,
+    })
+    assert.equal(author.ok, true)
+    assert.equal(author.attempt.route, 'author')
+    assert.equal(author.used, 2)
+    assert.equal(author.remaining, 0)
+    await writePlan(root, author.file)
+
+    const saved = await markReviewAttemptSaved(root, {
+      chapterNum: 7,
+      reviewInputToken: hash('a'),
+      mode: 'complete',
+    })
+    assert.equal(saved.ok, true)
+    assert.equal(saved.changed, true)
+    assert.equal(saved.attempt.status, 'saved')
+    await writePlan(root, saved.file)
+
+    const savedAgain = await markReviewAttemptSaved(root, {
+      chapterNum: 7,
+      reviewInputToken: hash('a'),
+      mode: 'complete',
+    })
+    assert.equal(savedAgain.ok, true)
+    assert.equal(savedAgain.changed, false)
+    assert.equal(savedAgain.file, null)
+
+    const cleared = await clearRetryPolicyChapters(root, [7, 7])
+    assert.equal(cleared.ok, true)
+    assert.equal(cleared.changed, true)
+    assert.deepEqual(cleared.state.chapters, {})
+    await writePlan(root, cleared.file)
+    assert.deepEqual(JSON.parse(await fs.readFile(path.join(root, RETRY_POLICY_PATH), 'utf8')).chapters, {})
+  } finally {
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})
+
+async function writePlan(root, file) {
+  await fs.mkdir(path.dirname(path.join(root, file.path)), { recursive: true })
+  await fs.writeFile(path.join(root, file.path), file.content, 'utf8')
+}

+ 64 - 1
v7/test/review/orchestration.test.js

@@ -2,7 +2,7 @@ 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 { assembleReviewInput, mergeReviews, runReviews, saveReviews } from '../../src/review/index.js'
 import { makeGitBook, chapter } from '../state-machine/_helper.js'
 
 const charCard = (name, 境界) => `---\n姓名: ${name}\n状态: 在世\n位置: 青云宗\n境界: ${境界}\n---\n## 设定\n。`
@@ -144,6 +144,69 @@ test('runReviews:降级模式 → 审稿单含兼容声明', async () => {
   } finally { await cleanup() }
 })
 
+test('runReviews:签发即预约两审轮次,第三轮在调用模型前拒绝,作者批准单独记账', async () => {
+  const { ctx, cleanup, root } = await makeReviewBook()
+  try {
+    let calls = 0
+    let seenToken = ''
+    const reviewers = {
+      factCheck: async (input) => {
+        calls++
+        seenToken = input.审稿输入令牌
+        return reviewedReport(input, { issues: [] })
+      },
+      editorial: async (input) => reviewedReport(input, { issues: [] }),
+    }
+    const budgetPath = path.join(root, '工作区', '重试预算.json')
+
+    const r1 = await runReviews(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md', mode: 'complete', reviewers })
+    assert.equal(r1.ok, true)
+    const afterFirst = JSON.parse(await fs.readFile(budgetPath, 'utf8'))
+    assert.equal(afterFirst.chapters['2'].review.attempts.length, 1)
+    assert.equal(afterFirst.chapters['2'].review.attempts[0].status, 'saved', '保存成功后轮次记为 saved')
+
+    // 作者裁决重提:同一令牌重新 save-review,不计新轮
+    const resaved = await saveReviews(ctx, {
+      chapterNum: 2,
+      rawFact: { 审稿输入令牌: seenToken, issues: [] },
+      rawEdit: { 审稿输入令牌: seenToken, issues: [] },
+      reviewInputToken: seenToken,
+      mode: 'complete',
+      draftPath: '工作区/草稿.md',
+    })
+    assert.equal(resaved.ok, true)
+    assert.ok(
+      (resaved.warnings || []).some((w) => w.includes('不计新轮')),
+      '覆盖保存已 saved 轮次必须给可见提醒'
+    )
+    const afterResave = JSON.parse(await fs.readFile(budgetPath, 'utf8'))
+    assert.equal(afterResave.chapters['2'].review.attempts.length, 1, '裁决重提不消耗新轮次')
+
+    const r2 = await runReviews(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md', mode: 'complete', reviewers })
+    assert.equal(r2.ok, true)
+
+    const callsBeforeThird = calls
+    const r3 = await runReviews(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md', mode: 'complete', reviewers })
+    assert.equal(r3.ok, false)
+    assert.match(r3.errors[0], /自动轮次已用完/)
+    assert.equal(calls, callsBeforeThird, '第三轮必须在调用模型前拒绝')
+
+    const r4 = await runReviews(ctx, {
+      chapterNum: 2,
+      draftPath: '工作区/草稿.md',
+      mode: 'complete',
+      reviewers,
+      authorApproved: true,
+    })
+    assert.equal(r4.ok, true)
+    const afterAuthor = JSON.parse(await fs.readFile(budgetPath, 'utf8'))
+    const attempts = afterAuthor.chapters['2'].review.attempts
+    assert.equal(attempts.length, 3)
+    assert.equal(attempts[2].route, 'author', '作者批准的轮次单独记账')
+    assert.equal(attempts.filter((a) => a.route === 'automatic').length, 2, '自动额度不被作者轮次恢复')
+  } finally { await cleanup() }
+})
+
 test('runReviews:审稿单越界 category → ok=false 带错', async () => {
   const { ctx, cleanup } = await makeReviewBook()
   try {

+ 5 - 3
v7/test/session/session.test.js

@@ -71,6 +71,8 @@ test('assembleSessionContext:有登记 → 注入含当前书与本数', async
     assert.equal(r.ok, true)
     assert.match(r.text, /剑起青云/)
     assert.match(r.text, /2 本/)
+    assert.match(r.text, /全书近况/)
+    assert.doesNotMatch(r.text, /PreToolUse|写前拦截|手改补登|relink/)
     assert.equal(r.current.书名, '剑起青云')
   } finally { await cleanup() }
 })
@@ -132,12 +134,12 @@ test('writeBooksRegistry:写 JSONL 逐行,可被 readBooksRegistry 读回', as
   } finally { await cleanup() }
 })
 
-test('无 hook 等价:hook 入口与状态机入口调同一函数 → 注入文本逐字一致', async () => {
+test('SessionStart 与无 hook SKILL 的 session-context 调同一函数 → 文本逐字一致', async () => {
   const { root, cleanup } = await tmpWorkdir()
   try {
     await writeRegistry(root, [JSON.stringify({ 书名: '剑起青云', 目录: '剑起青云', 当前: true })])
     const hookText = (await assembleSessionContext(root)).text // Claude Code SessionStart hook
-    const smText = (await assembleSessionContext(root)).text // 无 hook 宿主由状态机入口调
-    assert.equal(hookText, smText)
+    const skillText = (await assembleSessionContext(root)).text // 无 hook SKILL 显式运行 session-context
+    assert.equal(hookText, skillText)
   } finally { await cleanup() }
 })

+ 34 - 1
v7/test/staging/index.test.js

@@ -36,7 +36,7 @@ const 定稿章 = (num) =>
 
 function bookFiles({ 批次大小 = 3, 卷纲 = true } = {}) {
   const files = {
-    'book.yaml': `spec_version: "7.0"\n书名: 连写测试\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n连写批次大小: ${批次大小}\n`,
+    'book.yaml': `spec_version: "7.0"\n书名: 连写测试\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n文体基线起: 1\n文体基线止: 30\n连写批次大小: ${批次大小}\n`,
     '定稿/正文/0001-第1章.md': 定稿章(1),
     '定稿/正文/0002-第2章.md': 定稿章(2),
   }
@@ -262,6 +262,23 @@ test('批次质检:句式 vs 基线 29% 不停 31% 停;无基线跳过', asy
   assert.equal(judgeBatchQuality(at31, null, {}).过线, true, '无基线句式判据跳过')
 })
 
+test('停止条件:只读取当前配置的精确基线,忽略更晚的陈旧 active 行', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles())
+  try {
+    await ctx.cache.run(
+      "INSERT INTO fingerprints (chapter_range_start, chapter_range_end, is_baseline, avg_sentence_length, sentence_length_variance, avg_paragraph_length, common_phrase_frequency, vocabulary_richness, fingerprint_data) VALUES (1, 30, 1, 1, 0, 20, '{}', 0.5, '{}')"
+    )
+    await ctx.cache.run(
+      "INSERT INTO fingerprints (chapter_range_start, chapter_range_end, is_baseline, avg_sentence_length, sentence_length_variance, avg_paragraph_length, common_phrase_frequency, vocabulary_richness, fingerprint_data) VALUES (31, 40, 1, 100, 0, 20, '{}', 0.5, '{}')"
+    )
+    const r = await stage(ctx, 3, { 伏笔: ['推进 伏笔-001'] })
+    assert.equal(r.ok, true, r.error)
+    assert.ok(r.停止.reasons.some((reason) => reason.includes('批内平均句长')), JSON.stringify(r.停止))
+  } finally {
+    await cleanup()
+  }
+})
+
 // —— 打回 / 重审 / 丢弃 / 对账 ——
 
 test('rejectFrom:K 打回清工件,K+1..N 受影响;restageReview 回待审收', async () => {
@@ -323,11 +340,27 @@ test('discardBatch:整批丢弃,工作区批次消失', async () => {
   const { ctx, cleanup } = await repoCtx(null, bookFiles())
   try {
     await stage(ctx, 3)
+    await fs.writeFile(
+      path.join(ctx.repoPath, '工作区', '重试预算.json'),
+      JSON.stringify({
+        schemaVersion: 1,
+        chapters: {
+          3: { mechanical: { attempts: [{ draftHash: `sha256:${'a'.repeat(64)}`, route: 'initial', result: 'fail' }] }, review: { attempts: [] } },
+          99: { mechanical: { attempts: [{ draftHash: `sha256:${'b'.repeat(64)}`, route: 'initial', result: 'fail' }] }, review: { attempts: [] } },
+        },
+      }),
+      'utf8'
+    )
     const r = await discardBatch(ctx.repoPath)
     assert.equal(r.ok, true)
     assert.equal(r.章数, 1)
     assert.equal((await readBatch(ctx.repoPath)).exists, false)
     await assert.rejects(() => fs.access(path.join(ctx.repoPath, '工作区', '待定稿')))
+    const budget = JSON.parse(
+      await fs.readFile(path.join(ctx.repoPath, '工作区', '重试预算.json'), 'utf8')
+    )
+    assert.equal(budget.chapters['3'], undefined, '丢批清该批章的重试预算')
+    assert.ok(budget.chapters['99'], '批外章的重试预算保留')
   } finally {
     await cleanup()
   }

+ 28 - 0
v7/test/storage/parsers/book-config.test.js

@@ -47,6 +47,34 @@ test('空块列表统一为数组', () => {
   assert.deepEqual(result.data.流派, [])
 })
 
+test('文体基线起止必须是正整数', () => {
+  for (const [field, value] of [
+    ['文体基线起', 0],
+    ['文体基线起', -1],
+    ['文体基线起', 1.5],
+    ['文体基线止', 0],
+    ['文体基线止', -1],
+    ['文体基线止', 2.5],
+  ]) {
+    const result = parseBookConfig(`${field}: ${value}\n`)
+    assert.equal(result.ok, false, `${field}: ${value}`)
+    assert.equal(result.data, null)
+    assert.match(result.error, new RegExp(field))
+    assert.match(result.error, /正整数/)
+  }
+
+  const quoted = parseBookConfig('文体基线起: "1"\n')
+  assert.equal(quoted.ok, false)
+  assert.match(quoted.error, /文体基线起.*正整数/)
+})
+
+test('文体基线起不能大于文体基线止', () => {
+  const result = parseBookConfig('文体基线起: 31\n文体基线止: 30\n')
+  assert.equal(result.ok, false)
+  assert.equal(result.data, null)
+  assert.match(result.error, /文体基线起.*不能大于.*文体基线止/)
+})
+
 test('边界:YAML 语法错误', () => {
   const yaml = `书名: "未闭合`