Explorar el Código

feat(v7): DTO 静默降级显式标记——AI 可区分「没有」与「读失败」(P1 F6)

有损降级(读失败→空/残缺数据继续组装)此前在送达 AI 的 DTO 上不可见,
AI 会基于缺料材料自信写作/审稿;与真模型 smoke 全 deferred-beta 叠加时,
beta 真写 50 章的验证结论会失真。本次让降级显式可见:

- util/degradation.js 事件收集器挂 ctx 旁路(bin 挂载,零函数签名改动主链);
  85 站点三分类普查后只接有损 11+3 锚点(留档任务 research/degradation-sites.md):
  adapter 层三 Reader 加第三参(缓存坏→空且无文件 fallback 的真有损),
  组装层 7 处直报(含单卡失败定位到具体角色名、ENOENT 语义区分),
  state-machine 序2/序4/序6 + relink + A1/A2 锚点。
- 三处收口:备料材料「材料缺料提醒」段 + assembleReviewInput bind 前注入
  (审稿输入令牌覆盖 degraded,篡改即校验失败)+ mk() 统一兜底。
  无事件键不出现——省 token,旧消费者零感知。
- 良性降级(缓存坏→文件读成功,数据与真源等价)零处理不进通道(狼来了防护,
  签收口径:连诊断日志也不写)。
- SKILL 备料/两审步骤 + 两审任务书补消费约定:见 degraded 先向作者呈报缺料,
  degraded 板块按「无法核实」处理,不得断言无问题;四宿主壳重渲染。
- spec:story-repo 0.20 决策 63(三分类口径 + 收集器机制 + 三链路收口)。

验证(分工 kimi 实施 / claude 检查):全量 704 绿(基线 695+9)、drift 绿、
故障注入 9 例留档 fault-injection-output.txt;claude 独立探针证实令牌覆盖
degraded(含/不含令牌不同、篡改后校验失败)。

Refs 父任务 07-29-v7-review-findings F6
lingfengQAQ hace 2 meses
padre
commit
3b7d428519

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

@@ -1,6 +1,8 @@
-# Story Repo 格式规格(v7 现行 0.19)
+# Story Repo 格式规格(v7 现行 0.20)
 
-> 状态:0.19(2026-07-29 P0 路径穿越修复决策,决策 62 见 §14):AI 可控字段进文件路径一律白名单校验(信息差 id/时间线卷号/伏笔条目 id),写入器自卫 + 共享 payload 校验层 + writeAtomicBatch 总闸边界,章号/卷号入参统一正整数口径。
+> 状态:0.20(2026-07-30 DTO 降级显式标记决策,决策 63 见 §14):有损降级在送达 AI 的 DTO 上显式可见——AI 必须能区分「没有数据」与「读取失败后的残缺数据」。
+>
+> 0.19(2026-07-29 P0 路径穿越修复决策,决策 62 见 §14):AI 可控字段进文件路径一律白名单校验(信息差 id/时间线卷号/伏笔条目 id),写入器自卫 + 共享 payload 校验层 + writeAtomicBatch 总闸边界,章号/卷号入参统一正整数口径。
 >
 > 0.18(2026-07-23 发布审计决策,决策 58-61 见 §14;2026-07-28 勘误:重检幂等键为含章档案 front matter 的整份草稿 hash,只改章档案的修复同样消耗额度):完整章段后建立文体基线;机检/两审预算按章持久化;alpha 延后 PreToolUse;本 spec 完全不随 npm 分发。
 >
@@ -694,6 +696,12 @@ v6 的 8 个命令全部内化为以上状态。作者只需要一个入口和"
 
 ## 14. 决策记录
 
+### 0.19 → 0.20(依据:2026-07-30 DTO 降级显式标记,任务 07-29-p1-dto-degraded-flag)
+
+| # | 变更 | 落点 | 来源 |
+|---|------|------|------|
+| 63 | **有损降级必须在 DTO 上显式可见**:降级事件经 ctx 随行收集器(`util/degradation.js`,report/drain 旁路不改函数签名链)在有损点上报,DTO 组装收口 drain 为非空才写 `degraded` 数组(每项 `{site, reason}`,reason 截断 200);无事件时键不出现,旧消费者零感知。三分类口径:**有损降级**(读失败→空/残缺数据继续组装 DTO,数据与真源不等价)必须标记;**良性降级**(缓存坏→文件读成功,数据等价)不进 DTO(狼来了防护);**合理吞错**(ENOENT=语义「没有」或有既定设计注释)不标记。审稿输入的 `degraded` 在 `bindReviewInput` 之前注入(审稿输入令牌随之覆盖);备料在材料文本写「材料缺料提醒」段并在返回值带同名字段;状态机各序 DTO 在路由收口统一 drain 兜底,`期望产物` 说明同步补 degraded 语义。SKILL 备料/两审步骤与两审任务书约定消费方式:见 degraded 先向作者呈报缺料位置与原因、作者确认后再继续(事实审查/编辑审对 degraded 涉及板块按「无法核实」处理,不得断言无问题) | §10 序 0-6 DTO、§8 第 3/5 步 | 任务 `.trellis/tasks/07-29-p1-dto-degraded-flag/`(S0 普查 research/degradation-sites.md:85 站三分类、11 有损点 + 3 锚点全接);评审发现记录 `07-29-v7-review-findings` F6 |
+
 ### 0.18 → 0.19(依据:2026-07-29 P0 路径穿越修复,任务 07-29-p0-path-traversal)
 
 | # | 变更 | 落点 | 来源 |

+ 3 - 1
v7/bin/webnovel-writer.js

@@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs'
 import { CacheManager } from '../src/cache/index.js'
 import { checkNodeVersion } from '../src/runtime/node-version.js'
 import { resolveRunContext } from '../src/runtime/locate.js'
+import { createDegradationCollector } from '../src/util/degradation.js'
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url))
 
@@ -138,7 +139,8 @@ try {
     } else {
       cache = new CacheManager(path.join(plan.repoPath, '.cache', 'index.db'))
       await cache.ensureReady(plan.repoPath)
-      ctx = { repoPath: plan.repoPath, cache, workdir: plan.workdir ?? null, packageRoot }
+      // P1-F6:全书级降级事件收集器随行 ctx——有损降级点 report,DTO 组装收口处 drain
+      ctx = { repoPath: plan.repoPath, cache, workdir: plan.workdir ?? null, packageRoot, degradation: createDegradationCollector() }
     }
 
     const result = await mod.run(positionalArgs, options, ctx)

+ 1 - 1
v7/roles/事实审查.md

@@ -7,7 +7,7 @@ description: 读整章草稿,逐维度核出可验证的事实/一致性/逻
 你是事实审查员。只用传入的 ReviewInput 核对草稿,逐维度找出可验证的问题,输出问题清单 JSON。不读文件、不调脚本。
 
 ## 输入(ReviewInput)
-章号、作品契约版本、审稿输入令牌、草稿全文、本章要写到的事、全书近况、作品契约、知识审查、相关角色(境界/状态/位置/持有)、相关条目(含拟变动与近期履历)、名册、时间线片段、信息差候选、本章计划对象、批内待转正事实。计划对象只表示设计意图,不是已发生事实;定稿事实与正文证据优先。
+章号、作品契约版本、审稿输入令牌、草稿全文、本章要写到的事、全书近况、作品契约、知识审查、相关角色(境界/状态/位置/持有)、相关条目(含拟变动与近期履历)、名册、时间线片段、信息差候选、本章计划对象、批内待转正事实。计划对象只表示设计意图,不是已发生事实;定稿事实与正文证据优先。若有 `degraded` 数组:该处材料读取失败过、数据可能残缺——degraded 涉及的板块一律视为「无法核实」而非「确认无问题」,不得据此断言一致。
 
 ## 维度(category)
 - setting:能力/境界/地点/物品与相关角色及已建立规则一致

+ 1 - 1
v7/roles/编辑审.md

@@ -7,7 +7,7 @@ description: 读整章草稿,从结构/节奏/商业性/主角动机给出可
 你是编辑审稿员。只用传入的 ReviewInput 评估草稿的可读性与商业性,输出问题清单 JSON。不读文件、不调脚本。
 
 ## 输入(ReviewInput)
-章号、作品契约版本、审稿输入令牌、草稿全文、本章要写到的事、全书近况、作品契约、知识审查、相关角色、相关条目、时间线片段。本章知识只核对细纲最终选择的审稿切片,不从通用题材或流派库补规则。
+章号、作品契约版本、审稿输入令牌、草稿全文、本章要写到的事、全书近况、作品契约、知识审查、相关角色、相关条目、时间线片段。本章知识只核对细纲最终选择的审稿切片,不从通用题材或流派库补规则。若有 `degraded` 数组:该处材料读取失败过、数据可能残缺,涉及板块按「无法核实」处理,不得据此断言无问题。
 
 ## 维度(category)
 - structure:场景切分、信息密度、起承转合

+ 2 - 2
v7/skills/webnovel-writer/SKILL.md

@@ -30,9 +30,9 @@ SessionStart 已注入「当前在写哪本 / 共几本 / 全书近况入口」
 跨章复用或影响一致性的设定、人物、关系、地点、组织、能力和特殊物品才建计划对象;一次性路人、普通物品和临时地点不建档。对象设计阶段按真实问题用 `knowledge-query --维度=设定|人物|命名 [--类型=受控对象类型]` 取最多三条材料;候选可以选择、组合、修改、拒绝或完全自定义,不是固定答案。作者确认后生成完整 Markdown(front matter 至少有 `ID`、`名称`,可有 `对象类型`、`正名`、`别名`;正文必须有 `## 本书设计`、`## 一致性边界`、`## 知识依据`)。知识依据至少逐行写一个 `来源:<同维或命名维 canonical 文件>.md@sha256:<64 位小写十六进制>`;完全自定义/共创则精确写 `来源:作者自定义` / `来源:对谈共创`。最后必须且只能有一行非空 `本书适配:<采用、组合或改写说明>`,不得写其他自由说明行。通过 `{"作者已确认":true,"对象":[{"分类":"设定|人物","内容":"完整 Markdown"}],"放弃":[]}` 运行 `{{cmd}} persist-design --file=<json路径>`。规划与细纲用 `{{cmd}} read-design <ID或名称>` 精读;写作、审查和事实转正只读本书化计划对象与已确认事实,不回读未选的通用条目。
 
 ## 写章流程
-1. 备料:`{{cmd}} prepare-chapter <章号>`,读 `工作区/本章写作材料.md` 写草稿到 `工作区/草稿-A.md`。
+1. 备料:`{{cmd}} prepare-chapter <章号>`,读 `工作区/本章写作材料.md` 写草稿到 `工作区/草稿-A.md`。材料若带 `degraded` 段(材料缺料提醒):先向作者呈报缺料位置与原因,作者确认后才继续写稿,不得基于残缺材料静默写作。
 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`;其中 `作品契约版本` 与 `审稿输入令牌` 由脚本绑定完整输入。
+3. 两审:`{{cmd}} review-input <章号>` 生成 `工作区/审稿输入.json`;其中 `作品契约版本` 与 `审稿输入令牌` 由脚本绑定完整输入。ReviewInput 若带 `degraded` 数组:先向作者呈报缺料位置与原因,作者确认后才派两审,不得基于残缺上下文静默审稿。
    两审自动额度每章为初审一轮加自动重审一轮;`review-input` 提示超限被拒时停下交作者,作者明确批准重审后加 `--author-approved` 重发(单独记账,不恢复自动额度)。
 {{#if agentCapable}}
    派两个独立 subagent 按 `事实审查`、`编辑审` 任务书各读同一份审稿输入,各自新鲜上下文出报告。

+ 1 - 1
v7/src/commands/list-characters.js

@@ -5,7 +5,7 @@ import { EntityReader } from '../storage/adapters/EntityReader.js'
  * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
  */
 export async function run(args, options, ctx) {
-  const reader = new EntityReader(ctx.repoPath, ctx.cache)
+  const reader = new EntityReader(ctx.repoPath, ctx.cache, ctx.degradation)
   const filter = {}
   if (options.status && options.status !== true) filter.status = options.status
   const rows = await reader.listCharacters(filter)

+ 1 - 1
v7/src/commands/list-threads.js

@@ -6,7 +6,7 @@ import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
  * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
  */
 export async function run(args, options, ctx) {
-  const reader = new ThreadLedgerReader(ctx.repoPath, ctx.cache)
+  const reader = new ThreadLedgerReader(ctx.repoPath, ctx.cache, ctx.degradation)
 
   if (options['悬了太久']) {
     const config = await new BookConfigReader(ctx.repoPath).read()

+ 1 - 1
v7/src/commands/relink.js

@@ -13,7 +13,7 @@ export async function run(args, options, ctx) {
   if (!message) {
     return { ok: false, error: '缺少 --message=<一句话说明>(写进 commit,日后可查这次手改改了什么)' }
   }
-  const changes = await listManualEdits(ctx.repoPath)
+  const changes = await listManualEdits(ctx.repoPath, ctx.degradation)
   if (!changes.length) {
     return { ok: true, output: '作品契约/定稿/大纲/文风没有未登记的手改,无需补登。' }
   }

+ 1 - 1
v7/src/commands/report-overdue-threads.js

@@ -6,7 +6,7 @@ import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
  * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
  */
 export async function run(args, options, ctx) {
-  const threadReader = new ThreadLedgerReader(ctx.repoPath, ctx.cache)
+  const threadReader = new ThreadLedgerReader(ctx.repoPath, ctx.cache, ctx.degradation)
   const configReader = new BookConfigReader(ctx.repoPath)
 
   const configResult = await configReader.read()

+ 1 - 1
v7/src/dto/character-context.js

@@ -8,7 +8,7 @@ import { EntityReader } from '../storage/adapters/EntityReader.js'
  * @returns {Promise<{ok: boolean, context: object|null, error: string}>}
  */
 export async function assembleCharacterContext(ctx, name) {
-  const reader = new EntityReader(ctx.repoPath, ctx.cache)
+  const reader = new EntityReader(ctx.repoPath, ctx.cache, ctx.degradation)
   const r = await reader.readCharacterFrontMatter(name)
   if (!r.ok) {
     return { ok: false, context: null, error: r.error }

+ 12 - 4
v7/src/knowledge/chapter.js

@@ -224,10 +224,12 @@ export function flattenRecentChapterKnowledge(history) {
   return flattened
 }
 
-/** 最近若干章的选择历史;stagedChapters 覆盖同章定稿视图并参与统一倒序截断。 */
+/** 最近若干章的选择历史;stagedChapters 覆盖同章定稿视图并参与统一倒序截断。
+ * degradation:P1-F6 降级事件收集器(可选)——单章读失败静默跳章时上报。 */
 export async function readRecentChapterKnowledge(
   repoPath,
-  { before = Number.POSITIVE_INFINITY, limit = RECENT_KNOWLEDGE_CHAPTER_LIMIT, stagedChapters = [] } = {}
+  { before = Number.POSITIVE_INFINITY, limit = RECENT_KNOWLEDGE_CHAPTER_LIMIT, stagedChapters = [] } = {},
+  degradation = null
 ) {
   const chapters = new Map()
   for (const chapter of Array.isArray(stagedChapters) ? stagedChapters : []) {
@@ -258,9 +260,15 @@ export async function readRecentChapterKnowledge(
     if (!frontMatter) {
       try {
         const parsed = parseFrontMatter(await fs.readFile(source.filePath, 'utf8'))
-        if (!parsed.ok) continue
+        if (!parsed.ok) {
+          // P1-F6 有损:章文件存在但解析失败,该章知识历史消失
+          degradation?.report('readRecentChapterKnowledge.单章解析', `第${chapterNum}章:${parsed.error}`)
+          continue
+        }
         frontMatter = parsed.data
-      } catch {
+      } catch (err) {
+        // P1-F6 有损:章文件存在但读失败,该章知识历史消失
+        degradation?.report('readRecentChapterKnowledge.单章读', `第${chapterNum}章:${err.message}`)
         continue
       }
     }

+ 3 - 1
v7/src/prep/book-status.js

@@ -11,6 +11,8 @@ export async function assembleBookStatus(ctx) {
   try {
     const { cache, repoPath } = ctx
     const config = await new BookConfigReader(repoPath).read()
+    // P1-F6 有损(A1):book.yaml 读失败→卷规模等默认值静默进近况与阈值
+    if (!config.ok) ctx.degradation?.report('bookStatus.book.yaml', config.error)
     const bookConfig = config.ok ? config.data : {}
 
     const volRow = await cache.query('SELECT MAX(volume_num) AS v FROM chapters')
@@ -36,7 +38,7 @@ export async function assembleBookStatus(ctx) {
     }
 
     // 悬了太久(查询时算,阈值从 book.yaml)
-    const overdue = await new ThreadLedgerReader(repoPath, cache).listOverdue(bookConfig)
+    const overdue = await new ThreadLedgerReader(repoPath, cache, ctx.degradation).listOverdue(bookConfig)
 
     const 卷规模 = bookConfig.卷规模 || 40
     const data = {

+ 24 - 2
v7/src/prep/index.js

@@ -45,6 +45,8 @@ async function prepareChapterMaterialsLocked(ctx, { chapterNum }) {
 
     const facts = await stagedFacts(repoPath, { before: chapterNum })
     const status = overlayBookStatus(await assembleBookStatus(ctx), facts)
+    // P1-F6 有损:全书近况失败被静默消费(当前卷默认 1 → 时间线卷范围错)
+    if (!status.ok) ctx.degradation?.report('prepareChapterMaterials.全书近况', status.error)
     const 当前卷 = status.ok ? status.data.当前卷 : 1
 
     // 本章要写到的事 + 知识声明位(读细纲)
@@ -72,7 +74,7 @@ async function prepareChapterMaterialsLocked(ctx, { chapterNum }) {
     const 时间线md = 时间线行.length ? 时间线行.join('\n') : '(无)'
 
     // 信息差边界(未揭晓,勿泄):短题+知情人+关键词+内容首句——写稿 AI 知道秘密才守得住秘密
-    const secretReader = new SecretReader(repoPath, cache)
+    const secretReader = new SecretReader(repoPath, cache, ctx.degradation)
     const secrets = await secretReader.listUnrevealed()
     const 信息差行 = []
     for (const s of secrets) {
@@ -107,6 +109,8 @@ async function prepareChapterMaterialsLocked(ctx, { chapterNum }) {
       const reader = new ChapterReader(repoPath, cache)
       for (const r of recent.reverse()) {
         const t = await reader.readTail(r.chapter_num, 150)
+        // P1-F6 有损:章存在(缓存查出)但结尾读失败,空串拼接会被 AI 当作真实结尾
+        if (!t.ok) ctx.degradation?.report('prepareChapterMaterials.近章结尾', `第${r.chapter_num}章:${t.error}`)
         tails.push(`### 第${r.chapter_num}章结尾\n${t.ok ? t.text : ''}`)
       }
     }
@@ -160,9 +164,21 @@ async function prepareChapterMaterialsLocked(ctx, { chapterNum }) {
       // meta 读不到按未体检处理
     }
 
+    // P1-F6:有损降级事件——材料含缺料提醒(AI 读到必须向作者呈报),不进则无此段
+    const degradationEvents = ctx.degradation?.drain() || []
+    const degradedSection = degradationEvents.length
+      ? [
+          `## 材料缺料提醒(degraded)`,
+          `本次组装发生 ${degradationEvents.length} 处读取失败,以下材料可能缺料,请向作者呈报并确认后再继续:`,
+          ...degradationEvents.map((e) => `- ${e.site}:${e.reason}`),
+          '',
+        ]
+      : []
+
     const parts = [
       `# 第 ${chapterNum} 章写作材料`,
       '',
+      ...degradedSection,
       status.ok ? status.markdown : '## 全书近况\n(组装失败)',
       '',
       `## 本章要写到的事\n${要写到的事}`,
@@ -190,7 +206,13 @@ async function prepareChapterMaterialsLocked(ctx, { chapterNum }) {
     const filePath = path.join(dir, '本章写作材料.md')
     await fs.writeFile(filePath, content, 'utf8')
 
-    return { ok: true, filePath, content, error: '' }
+    return {
+      ok: true,
+      filePath,
+      content,
+      ...(degradationEvents.length ? { degraded: degradationEvents } : {}),
+      error: '',
+    }
   } catch (err) {
     return { ok: false, filePath: '', content: '', error: `备料失败:${err.message}` }
   }

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

@@ -105,6 +105,8 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
     }
 
     const status = overlayBookStatus(await assembleBookStatus(ctx), facts)
+    // P1-F6 有损:全书近况失败被静默消费(当前卷默认 1 → 时间线卷范围错;近况变空串)
+    if (!status.ok) ctx.degradation?.report('assembleReviewInput.全书近况', status.error)
     const 当前卷 = status.ok ? status.data.当前卷 : 1
 
     // P1-1:名册快照(正名+别名),供 AI 判新专名,不泄漏路径;同时建 aliasMap 供角色别名命中
@@ -119,8 +121,9 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
         if (别名.length) aliasMap.set(r.正名, 别名)
         return { 正名: r.正名, 别名 }
       })
-    } catch {
-      // 缓存不可用则略
+    } catch (err) {
+      // 缓存不可用则略(P1-F6 有损:空名册会让 AI 判新专名失真,进 degraded 可见)
+      ctx.degradation?.report('assembleReviewInput.名册', err.message)
     }
     // 批内预登记的名册行(新角色/新别名)
     for (const pair of facts.roster) {
@@ -142,7 +145,11 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
         if (!f.endsWith('.md')) continue
         const name = f.replace(/\.md$/, '')
         const cc = await assembleCharacterContext(ctx, name)
-        if (!cc.ok) continue
+        // P1-F6 有损:单卡读失败静默跳员,AI 看不到在场角色
+        if (!cc.ok) {
+          ctx.degradation?.report('assembleReviewInput.相关角色(单卡)', `${name}:${cc.error}`)
+          continue
+        }
         // 别名合集:名册 entity_aliases(规范源)+ 角色卡 fm.别名
         const aliases = new Set(aliasMap.get(name) || [])
         const fmAliases = cc.context.别名
@@ -155,8 +162,11 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
         const upd = facts.characterUpdates.get(name)
         相关角色.push(upd ? { ...cc.context, ...upd } : cc.context)
       }
-    } catch {
-      // 无角色目录
+    } catch (err) {
+      // 无角色目录(ENOENT=语义「没有角色」,合理吞错;其他读失败=有损,进 degraded)
+      if (err?.code !== 'ENOENT') {
+        ctx.degradation?.report('assembleReviewInput.相关角色(目录)', err.message)
+      }
     }
 
     const tl = await new TimelineReader(repoPath, cache).readVolumeRange(Math.max(1, 当前卷 - 1), 当前卷)
@@ -165,7 +175,7 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
       时间线片段.push({ 章: tr.row?.章 ?? '', 事件: `${tr.row?.一句话事件 ?? ''}(批内预登记)` })
     }
 
-    const secrets = await new SecretReader(repoPath, cache).listUnrevealed()
+    const secrets = await new SecretReader(repoPath, cache, ctx.degradation).listUnrevealed()
     const 信息差候选 = secrets.map((s) => ({ id: s.id, 短题: s.短题, 关键词: s.关键词 }))
     for (const s of facts.secretWrites) {
       const fm = s.frontMatter || {}
@@ -188,8 +198,9 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
         开启章: r.opened_chapter,
         最后推进章: r.last_advanced_chapter,
       }))
-    } catch {
-      // 缓存不可用则略
+    } catch (err) {
+      // 缓存不可用则略(P1-F6 有损:空条目清单会让 AI 看不到既有伏笔)
+      ctx.degradation?.report('assembleReviewInput.相关条目', err.message)
     }
     // 批内预登记的条目事实:新开条目补进清单,已有条目刷状态/最后推进章
     for (const [id, t] of facts.threads) {
@@ -213,10 +224,12 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
     // 草稿声明涉及的条目附履历尾部(末 3 行);未声明的维持纯元数据(控 token)
     const declaredIds = new Set(拟条目变动.map((d) => d.id))
     if (declaredIds.size) {
-      const ledger = new ThreadLedgerReader(repoPath, cache)
+      const ledger = new ThreadLedgerReader(repoPath, cache, ctx.degradation)
       for (const t of 相关条目) {
         if (!declaredIds.has(t.id)) continue
         const h = await ledger.readHistory(t.id)
+        // P1-F6 有损(A3):已声明条目履历读失败,静默不加履历尾部
+        if (!h.ok) ctx.degradation?.report('assembleReviewInput.履历尾部', `${t.id}:${h.error}`)
         if (h.ok && h.history.length) t.履历尾部 = h.history.slice(-3).map((x) => x.原文)
       }
     }
@@ -225,6 +238,9 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
     if (!Number.isInteger(contractVersion) || contractVersion < 1) {
       return { ok: false, input: null, error: '组装审稿输入停止:作品契约版本无效' }
     }
+    // P1-F6:有损降级事件在 bind 前注入——审稿输入令牌(全字段 sha256)随之覆盖 degraded,
+    // AI 必须据 degraded 区分「没有数据」与「读取失败后的残缺数据」。
+    const degradationEvents = ctx.degradation?.drain() || []
     const input = bindReviewInput({
         章号: chapterNum,
         作品契约版本: contractVersion,
@@ -261,6 +277,7 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
         ...(designObjects.missing.length
           ? { 计划对象缺失提醒: `未找到:${designObjects.missing.join('、')}。按正文实际内容审查,不要求临时对象补档。` }
           : {}),
+        ...(degradationEvents.length ? { degraded: degradationEvents } : {}),
     })
     return { ok: true, input, error: '' }
   } catch (err) {

+ 8 - 5
v7/src/state-machine/detectors.js

@@ -143,8 +143,9 @@ export async function bookMissing(repoPath) {
 }
 
 /** 序2:跟踪面(TRACKED_SOURCE_PREFIXES + book.yaml)下未登记的手改清单(git 工作树未提交改动,含未跟踪新文件)。
- * 供状态机出 dto 变更清单、relink 补登命令圈定 add 范围——检测与执行同源,不双写。 */
-export async function listManualEdits(repoPath) {
+ * 供状态机出 dto 变更清单、relink 补登命令圈定 add 范围——检测与执行同源,不双写。
+ * degradation:P1-F6 降级事件收集器(可选)——git 不可用时清单为空会导致序2 静默漏检,须上报。 */
+export async function listManualEdits(repoPath, degradation = null) {
   try {
     const status = await createGit(repoPath).status()
     const paths = []
@@ -155,14 +156,16 @@ export async function listManualEdits(repoPath) {
       }
     }
     return paths
-  } catch {
+  } catch (err) {
+    // P1-F6 有损(低置信,checkGitHealth guidance 部分缓释):git 不可用→序2 静默放行
+    degradation?.report('listManualEdits', err.message)
     return []
   }
 }
 
 /** 序2:定稿/大纲 有未登记手改(git 工作树有未提交改动) */
-export async function hasManualEdits(repoPath) {
-  return (await listManualEdits(repoPath)).length > 0
+export async function hasManualEdits(repoPath, degradation = null) {
+  return (await listManualEdits(repoPath, degradation)).length > 0
 }
 
 /**

+ 9 - 3
v7/src/state-machine/dto.js

@@ -85,6 +85,8 @@ export async function buildDto(ctx, 序, base = {}) {
         }
       }
       const status = await assembleBookStatus(ctx)
+      // P1-F6 有损:全书近况失败→空串/空清单静默进序4 DTO
+      if (!status.ok) ctx.degradation?.report('dto.序4.全书近况', status.error)
       const contractHistory = await readContractIssueHistory(ctx.repoPath, { volume: base.卷 })
       const contractSummary = summarizeContractIssues(contractHistory)
       return {
@@ -96,7 +98,7 @@ export async function buildDto(ctx, 序, base = {}) {
         作品契约复盘: contractSummary.length
           ? contractSummary
           : '本卷没有实际记录的作品契约问题,默认保持现契约不变。',
-        期望产物: '卷摘要 + 下卷卷纲 + 伏笔机会候选(作者勾选后 M3 生成条目)+ 作品契约复盘;契约无问题就保持不变,有问题也只提出修订候选,作者确认且修订 JSON 已记录证据与影响范围后才运行 persist-contract',
+        期望产物: '卷摘要 + 下卷卷纲 + 伏笔机会候选(作者勾选后 M3 生成条目)+ 作品契约复盘;契约无问题就保持不变,有问题也只提出修订候选,作者确认且修订 JSON 已记录证据与影响范围后才运行 persist-contract。若本 DTO 带 degraded 字段:有读取失败发生过,相应材料可能缺料,先向作者呈报缺料位置与原因、作者确认后再继续',
       }
     }
     case 6: {
@@ -109,7 +111,11 @@ export async function buildDto(ctx, 序, base = {}) {
         }
       }
       const status = await assembleBookStatus(ctx)
+      // P1-F6 有损:全书近况失败→空串静默进序6 DTO
+      if (!status.ok) ctx.degradation?.report('dto.序6.全书近况', status.error)
       const config = await new BookConfigReader(ctx.repoPath).read()
+      // P1-F6 有损(A2):book.yaml 读失败→「自动确认细纲」静默 =false
+      if (!config.ok) ctx.degradation?.report('dto.序6.book.yaml', config.error)
       const 自动确认细纲 = !!(config.ok && config.data.自动确认细纲)
       const 知识 = await chapterKnowledge(ctx, {
         当前卷: status.ok ? status.data.当前卷 : 1,
@@ -132,7 +138,7 @@ export async function buildDto(ctx, 序, base = {}) {
           自动确认细纲
             ? '工作区/细纲.md(含本章定位声明 + 本章要写到的事 + 备选,由 M3 落盘);自动确认细纲已开:提案直接 persist-outline 生效,不再问作者;卷近尾声时提案可含收卷提议'
             : '工作区/细纲.md(含本章定位声明 + 本章要写到的事 + 备选,由 M3 落盘);卷近尾声时提案可含收卷提议(依据卷纲进度与卷规模参考值,作者确认后定稿写入 收卷: 是)'
-        };章级候选只是少量材料,不是固定答案。可组合、变体或完全自定义,作者确认整份细纲即确认这些软策略。提案段按需声明(皆可空、多选):本章节拍 / 本章场景 / 本章技法 / 本章追读;实际修改或自定义另写知识变体,本章涉及计划对象时写本章对象。`,
+        };章级候选只是少量材料,不是固定答案。可组合、变体或完全自定义,作者确认整份细纲即确认这些软策略。提案段按需声明(皆可空、多选):本章节拍 / 本章场景 / 本章技法 / 本章追读;实际修改或自定义另写知识变体,本章涉及计划对象时写本章对象。若本 DTO 带 degraded 字段:有读取失败发生过,相应材料可能缺料,先向作者呈报缺料位置与原因、作者确认后再继续。`,
       }
     }
     default:
@@ -154,7 +160,7 @@ async function chapterKnowledge(ctx, { 当前卷, nextChapter, 本章任务 }) {
   const recent = await readRecentChapterKnowledge(ctx.repoPath, {
     before: nextChapter,
     stagedChapters: staged.chapters,
-  })
+  }, ctx.degradation)
   const recentSelections = flattenRecentChapterKnowledge(recent)
   const recentContext = {
     近期知识历史: await toPlanningKnowledgeHistory(ctx.packageRoot, recent),

+ 15 - 11
v7/src/state-machine/index.js

@@ -14,7 +14,7 @@ export async function determineNextState(ctx) {
 
   // 空工作目录(bin 定位后无当前书):没有书仓库可查,直接序1 建书引导(spec §10 序1「工作目录无任何书」)
   if (!repoPath) {
-    return mk(1, 'create-book', true, '工作目录还没有书,进入建书引导。', { fixed: [], guidance: [] }, await buildDto(ctx, 1, {}))
+    return mk(1, 'create-book', true, '工作目录还没有书,进入建书引导。', { fixed: [], guidance: [] }, await buildDto(ctx, 1, {}), ctx)
   }
 
   const gitHealth = await checkGitHealth(ctx)
@@ -27,24 +27,24 @@ export async function determineNextState(ctx) {
     (failure) => failure.file !== '作品契约/作品契约.md'
   )
   if (repairFailures.length) {
-    return mk(0, 'repair-confirm', true, `检测到 ${repairFailures.length} 个源文件解析失败,需逐个修复确认。`, gitHealth, await buildDto(ctx, 0, { failures: repairFailures }))
+    return mk(0, 'repair-confirm', true, `检测到 ${repairFailures.length} 个源文件解析失败,需逐个修复确认。`, gitHealth, await buildDto(ctx, 0, { failures: repairFailures }), ctx)
   }
 
   // 序1 建书引导
   if (await d.bookMissing(repoPath)) {
-    return mk(1, 'create-book', true, '当前目录还没有书,进入建书引导。', gitHealth, await buildDto(ctx, 1, {}))
+    return mk(1, 'create-book', true, '当前目录还没有书,进入建书引导。', gitHealth, await buildDto(ctx, 1, {}), ctx)
   }
 
   // 序2 手改补登(检测=脚本;补登执行体也是脚本:relink 命令,作者确认后跑)
-  const manualEdits = await d.listManualEdits(repoPath)
+  const manualEdits = await d.listManualEdits(repoPath, ctx.degradation)
   if (manualEdits.length) {
-    return mk(2, 'relink-manual-edits', false, `书仓源文件有 ${manualEdits.length} 处未登记的手改,问作者「补登吗」,确认后运行 relink --message=<一句话说明> 入档。`, gitHealth, await buildDto(ctx, 2, { manualEdits }))
+    return mk(2, 'relink-manual-edits', false, `书仓源文件有 ${manualEdits.length} 处未登记的手改,问作者「补登吗」,确认后运行 relink --message=<一句话说明> 入档。`, gitHealth, await buildDto(ctx, 2, { manualEdits }), ctx)
   }
 
   // 序3 断点续跑
   const unfinished = await d.unfinishedWorkDetail(repoPath)
   if (unfinished.现存.length) {
-    return mk(3, 'resume', false, `工作区有未完成的流程(${unfinished.现存.join('、')}),从「${unfinished.从哪继续}」继续。`, gitHealth, await buildDto(ctx, 3, unfinished))
+    return mk(3, 'resume', false, `工作区有未完成的流程(${unfinished.现存.join('、')}),从「${unfinished.从哪继续}」继续。`, gitHealth, await buildDto(ctx, 3, unfinished), ctx)
   }
 
   // 序4/5/6 需章号信息。查询失败不裸抛也不降级成「第 1 章」(D7)——
@@ -86,13 +86,13 @@ export async function determineNextState(ctx) {
         message: volumeDto.阻断原因,
       }
     }
-    return mk(4, 'volume-review', true, `第 ${last.chapter_num} 章已收卷,进入第 ${last.volume_num} 卷复盘。`, gitHealth, volumeDto)
+    return mk(4, 'volume-review', true, `第 ${last.chapter_num} 章已收卷,进入第 ${last.volume_num} 卷复盘。`, gitHealth, volumeDto, ctx)
   }
 
   // 序5 体检(距上次体检 ≥ 体检周期;记录存缓存 meta,丢失重测无害。统计项随 M5.5,最小体检=health-check 命令)
   const lastCheck = await readLastHealthCheck(cache)
   if (maxChapter > 0 && maxChapter - lastCheck >= 体检周期) {
-    return mk(5, 'health-check', false, `距上次体检已 ${maxChapter - lastCheck} 章(周期 ${体检周期}),进入体检。`, gitHealth, {})
+    return mk(5, 'health-check', false, `距上次体检已 ${maxChapter - lastCheck} 章(周期 ${体检周期}),进入体检。`, gitHealth, {}, ctx)
   }
 
   // 序6 起草新章细纲(近况=脚本,拟提案=AI)
@@ -110,11 +110,15 @@ export async function determineNextState(ctx) {
       message: draftDto.阻断原因,
     }
   }
-  return mk(6, 'draft-outline', true, `起草第 ${maxChapter + 1} 章细纲。`, gitHealth, draftDto)
+  return mk(6, 'draft-outline', true, `起草第 ${maxChapter + 1} 章细纲。`, gitHealth, draftDto, ctx)
 }
 
-function mk(序, state, needsAI, message, gitHealth, dto) {
-  return { ok: true, 序, state, needsAI, message, gitHealth, dto }
+function mk(序, state, needsAI, message, gitHealth, dto, ctx = null) {
+  // P1-F6:收口兜底——DTO 前各种有损降级在此统一 drain 进 dto.degraded(无事件不出键)。
+  // AI 据此区分「没有数据」与「读取失败后的残缺数据」。
+  const degradationEvents = ctx?.degradation?.drain() || []
+  const finalDto = degradationEvents.length ? { ...dto, degraded: degradationEvents } : dto
+  return { ok: true, 序, state, needsAI, message, gitHealth, dto: finalDto }
 }
 
 async function readLastHealthCheck(cache) {

+ 5 - 1
v7/src/storage/adapters/EntityReader.js

@@ -9,9 +9,11 @@ import { sanitizeFileName } from '../../util/filename.js'
  * EntityReader:读取角色卡、解析别名。
  */
 export class EntityReader {
-  constructor(repoPath, cache = null) {
+  constructor(repoPath, cache = null, degradation = null) {
     this.repoPath = repoPath
     this.cache = cache
+    // P1-F6 降级事件收集器(ctx 随行旁路,可选);有损降级发生时 report
+    this.degradation = degradation
   }
 
   /**
@@ -118,6 +120,8 @@ export class EntityReader {
         const rows = await this.cache.query(query, params)
         return rows
       } catch (err) {
+        // P1-F6 有损降级:缓存坏→[](不走文件扫描分支——该分支只在无缓存时可达)
+        this.degradation?.report('EntityReader.listCharacters', err.message)
         return []
       }
     }

+ 5 - 1
v7/src/storage/adapters/SecretReader.js

@@ -15,9 +15,11 @@ function parseJSONArray(text) {
  * SecretReader:读取信息差。
  */
 export class SecretReader {
-  constructor(repoPath, cache = null) {
+  constructor(repoPath, cache = null, degradation = null) {
     this.repoPath = repoPath
     this.cache = cache
+    // P1-F6 降级事件收集器(ctx 随行旁路,可选);有损降级发生时 report
+    this.degradation = degradation
   }
 
   async readBasicInfo(id) {
@@ -92,6 +94,8 @@ export class SecretReader {
         登记章: r.registered_chapter,
       }))
     } catch (err) {
+      // P1-F6 有损降级:缓存坏→空清单(无文件 fallback),DTO 照常组装并宣称「(无)」
+      this.degradation?.report('SecretReader.listUnrevealed', err.message)
       return []
     }
   }

+ 7 - 1
v7/src/storage/adapters/ThreadLedgerReader.js

@@ -24,9 +24,11 @@ function threadRowToFrontMatter(row) {
  * ThreadLedgerReader:读取三类条目(伏笔/悬念/感情线)。
  */
 export class ThreadLedgerReader {
-  constructor(repoPath, cache = null) {
+  constructor(repoPath, cache = null, degradation = null) {
     this.repoPath = repoPath
     this.cache = cache
+    // P1-F6 降级事件收集器(ctx 随行旁路,可选);有损降级发生时 report
+    this.degradation = degradation
   }
 
   /**
@@ -185,6 +187,8 @@ export class ThreadLedgerReader {
 
       return filtered
     } catch (err) {
+      // P1-F6 有损降级:缓存坏→[],全书近况宣称「悬了太久:无」
+      this.degradation?.report('ThreadLedgerReader.listOverdue', err.message)
       return []
     }
   }
@@ -212,6 +216,8 @@ export class ThreadLedgerReader {
       const rows = await this.cache.query(query, params)
       return rows
     } catch (err) {
+      // P1-F6 有损降级:缓存坏→[]
+      this.degradation?.report('ThreadLedgerReader.listByType', err.message)
       return []
     }
   }

+ 28 - 0
v7/src/util/degradation.js

@@ -0,0 +1,28 @@
+/**
+ * 降级事件收集器(P1-F6):ctx 随行的旁路通道。
+ * 有损降级(读失败→空/残缺数据继续组装 DTO)在各降级点 report,DTO 组装收口处 drain,
+ * 非空才写 `degraded` 字段——AI 据此区分「没有数据」与「读取失败后的残缺数据」。
+ * 良性降级(缓存坏→文件读成功,数据与真源等价)不进此通道,避免狼来了。
+ *
+ * 用法:ctx.degradation?.report(site, reason)——可选链保证无收集器时零影响
+ * (测试/旧调用点的 ctx 没有 degradation 键,天然兼容)。
+ */
+export function createDegradationCollector() {
+  const events = []
+  return {
+    /**
+     * @param {string} site 发生位置(如 'SecretReader.listUnrevealed')
+     * @param {*} reason 原因摘要(归一为字符串,截断 200——DTO 是给 AI 的材料不是日志)
+     */
+    report(site, reason) {
+      events.push({ site: String(site), reason: String(reason ?? '').slice(0, 200) })
+    },
+    /** 取走全部事件并清空(DT0 组装收口处调用一次)。 */
+    drain() {
+      return events.splice(0)
+    },
+    get size() {
+      return events.length
+    },
+  }
+}

+ 60 - 0
v7/test/prep/degraded.test.js

@@ -0,0 +1,60 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { prepareChapterMaterials } from '../../src/prep/index.js'
+import { tempBookCtx } from '../commands/_helper.js'
+import { createDegradationCollector } from '../../src/util/degradation.js'
+
+/**
+ * P1-F6 备料链:故障注入——cache.query 对 secrets 查询恒抛(模拟缓存损坏)。
+ * 期望:DTO 返回值与材料文本都带 degraded(含位置与原因),无故障时两键全不出现。
+ */
+
+function breakQuery(cache, includes) {
+  const orig = cache.query.bind(cache)
+  cache.query = async (sql, params) => {
+    if (String(sql).includes(includes)) {
+      const err = new Error('注入:SQLITE_CORRUPT 模拟缓存损坏')
+      err.code = 'SQLITE_CORRUPT'
+      throw err
+    }
+    return orig(sql, params)
+  }
+}
+
+test('备料链:secrets 缓存损坏 → DTO 与材料文本都带 degraded', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    ctx.degradation = createDegradationCollector()
+    breakQuery(ctx.cache, 'FROM secrets')
+
+    const r = await prepareChapterMaterials(ctx, { chapterNum: 3 })
+    assert.equal(r.ok, true, r.error)
+    assert.ok(Array.isArray(r.degraded), '返回值必须带 degraded 数组')
+    const secretEvent = r.degraded.find((e) => e.site === 'SecretReader.listUnrevealed')
+    assert.ok(secretEvent, 'degraded 含 SecretReader.listUnrevealed 事件')
+    assert.match(secretEvent.reason, /SQLITE_CORRUPT/)
+
+    assert.match(r.content, /## 材料缺料提醒(degraded)/, '材料文本含缺料提醒段')
+    assert.match(r.content, /SecretReader\.listUnrevealed/, '材料文本含站点定位')
+
+    const onDisk = await fs.readFile(path.join(ctx.repoPath, '工作区', '本章写作材料.md'), 'utf8')
+    assert.match(onDisk, /材料缺料提醒/, '落盘材料同样可见缺料提醒')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('备料链:无故障 → degraded 键与提醒段全部不出现(旧消费者零感知)', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    ctx.degradation = createDegradationCollector()
+    const r = await prepareChapterMaterials(ctx, { chapterNum: 3 })
+    assert.equal(r.ok, true, r.error)
+    assert.equal('degraded' in r, false, '无事件时返回对象不得有 degraded 键')
+    assert.doesNotMatch(r.content, /材料缺料提醒/, '无事件时材料文本不得有提醒段')
+  } finally {
+    await cleanup()
+  }
+})

+ 113 - 0
v7/test/review/degraded.test.js

@@ -0,0 +1,113 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { assembleReviewInput } from '../../src/review/index.js'
+import { makeGitBook } from '../state-machine/_helper.js'
+import { createDegradationCollector } from '../../src/util/degradation.js'
+import { ChapterReader } from '../../src/storage/adapters/ChapterReader.js'
+
+/**
+ * P1-F6 审稿输入链:故障注入——cache.query 对 secrets/entities/threads 恒抛。
+ * 期望:ReviewInput 带 degraded(bind 前注入、令牌覆盖);角色卡读失败的单卡跳员也定位到具体角色。
+ * 良性降级(缓存坏、文件在)不产生事件(狼来了防护)。
+ */
+
+const charCard = (name, 境界) => `---\n姓名: ${name}\n状态: 在世\n位置: 青云宗\n境界: ${境界}\n---\n## 设定\n。`
+
+async function makeReviewBook(files = {}) {
+  return makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n',
+    '定稿/正文/0001-起.md': '---\n章号: 1\n标题: 起\n卷: 1\n---\n过去的事。',
+    '定稿/设定/角色/林晚.md': charCard('林晚', '练气三层'),
+    '定稿/设定/时间线/第01卷.md': '| 章 | 一句话事件 |\n| --- | --- |\n| 1 | 林晚得玉佩 |\n',
+    '工作区/细纲.md': '## 本章要写到的事\n林晚突破练气四层。\n',
+    '工作区/草稿.md': '林晚运转功法,突破到练气四层。她握紧青霜剑。',
+    ...files,
+  })
+}
+
+function breakQueries(cache, pattern) {
+  const orig = cache.query.bind(cache)
+  cache.query = async (sql, params) => {
+    if (String(sql).includes(pattern)) {
+      const err = new Error('注入:SQLITE_CORRUPT 模拟缓存损坏')
+      err.code = 'SQLITE_CORRUPT'
+      throw err
+    }
+    return orig(sql, params)
+  }
+}
+
+test('审稿输入链:secrets/entities/threads 缓存损坏 → ReviewInput 带 degraded 且令牌覆盖', async () => {
+  const { ctx, cleanup } = await makeReviewBook()
+  try {
+    ctx.degradation = createDegradationCollector()
+    breakQueries(ctx.cache, 'FROM secrets')
+    const origEntities = ctx.cache.query.bind(ctx.cache)
+    const breaker = ctx.cache.query
+    ctx.cache.query = async (sql, params) => {
+      if (String(sql).includes('FROM entities') || String(sql).includes('FROM threads')) {
+        const err = new Error('注入:SQLITE_CORRUPT 模拟缓存损坏')
+        err.code = 'SQLITE_CORRUPT'
+        throw err
+      }
+      return breaker(sql, params)
+    }
+
+    const r = await assembleReviewInput(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md' })
+    assert.equal(r.ok, true, r.error)
+    const events = r.input.degraded || []
+    assert.ok(events.length >= 3, `至少三处降级事件,实得 ${events.length}`)
+    const sites = events.map((e) => e.site)
+    assert.ok(sites.includes('SecretReader.listUnrevealed'), '信息差点位在')
+    assert.ok(sites.includes('assembleReviewInput.名册'), '名册点位在')
+    assert.ok(sites.includes('assembleReviewInput.相关条目'), '相关条目点位在')
+    assert.match(r.input.审稿输入令牌, /^sha256:[0-9a-f]{64}$/, '令牌照常签发')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('审稿输入链:角色卡读失败 → degraded 定位到具体角色名', async () => {
+  const { ctx, cleanup } = await makeReviewBook({
+    '定稿/设定/角色/残卡.md': '---\n坏: yaml: :\n---\n无法解析的卡。',
+  })
+  try {
+    ctx.degradation = createDegradationCollector()
+    const r = await assembleReviewInput(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md' })
+    assert.equal(r.ok, true, r.error)
+    const cardEvent = (r.input.degraded || []).find(
+      (e) => e.site === 'assembleReviewInput.相关角色(单卡)'
+    )
+    assert.ok(cardEvent, '单卡读失败必须有 degraded 事件')
+    assert.match(cardEvent.reason, /残卡/, '原因定位到具体角色文件名')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('审稿输入链:无故障 → degraded 键不出现(旧断言零改动)', async () => {
+  const { ctx, cleanup } = await makeReviewBook()
+  try {
+    ctx.degradation = createDegradationCollector()
+    const r = await assembleReviewInput(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md' })
+    assert.equal(r.ok, true, r.error)
+    assert.equal('degraded' in r.input, false, '无事件时 ReviewInput 不得有 degraded 键')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('良性降级:ChapterReader 缓存坏、文件在 → 数据不缺,不产生事件(狼来了防护)', async () => {
+  const { ctx, cleanup } = await makeReviewBook()
+  try {
+    const degradation = createDegradationCollector()
+    breakQueries(ctx.cache, 'FROM chapters')
+    const reader = new ChapterReader(ctx.repoPath, ctx.cache, degradation)
+    const fm = await reader.readFrontMatter(1)
+    assert.equal(fm.ok, true, '缓存坏时文件兜底必须成功')
+    assert.equal(fm.data.标题, '起', '文件读到的数据与真源等价')
+    assert.equal(degradation.size, 0, '良性降级不得产生 degraded 事件')
+  } finally {
+    await cleanup()
+  }
+})

+ 38 - 0
v7/test/util/degradation.test.js

@@ -0,0 +1,38 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { createDegradationCollector } from '../../src/util/degradation.js'
+
+/**
+ * P1-F6:降级事件收集器(ctx 随行旁路)。
+ * 收集有损降级事件(读失败→空/残缺数据继续组装 DTO);良性降级(缓存坏→文件读成功)不进此通道。
+ */
+
+test('degradation collector:report 收集 site/reason,drain 清空', () => {
+  const c = createDegradationCollector()
+  assert.equal(c.size, 0)
+  assert.deepEqual(c.drain(), [])
+
+  c.report('SecretReader.listUnrevealed', 'SQLITE_CORRUPT: database disk image is malformed')
+  c.report('review.roster', Error('boom').message)
+  assert.equal(c.size, 2)
+  const events = c.drain()
+  assert.equal(events.length, 2)
+  assert.equal(events[0].site, 'SecretReader.listUnrevealed')
+  assert.match(events[0].reason, /SQLITE_CORRUPT/)
+  assert.deepEqual(c.drain(), [], 'drain 后清空')
+})
+
+test('degradation collector:reason 截断 200 字符(DTO 是材料不是日志)', () => {
+  const c = createDegradationCollector()
+  c.report('x', 'a'.repeat(500))
+  assert.equal(c.drain()[0].reason.length, 200)
+})
+
+test('degradation collector:site/reason 归一为字符串', () => {
+  const c = createDegradationCollector()
+  c.report('x', null)
+  c.report('y', undefined)
+  const events = c.drain()
+  assert.equal(typeof events[0].reason, 'string')
+  assert.equal(typeof events[1].reason, 'string')
+})