Pārlūkot izejas kodu

feat(v7): M6 P2——备料/审稿输入/机检叠加视图(批内依赖,before 过滤防倒灌)

lingfengQAQ 1 dienu atpakaļ
vecāks
revīzija
f05aa7ab0b

+ 21 - 7
v7/src/mechanical-check/index.js

@@ -4,6 +4,7 @@ import { parseFrontMatter } from '../storage/parsers/front-matter.js'
 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'
 
 // front matter 章档案必填字段(§4.1 机器消费部分)
 const REQUIRED_FM = ['章号', '标题', '卷', '字数', '章定位', '钩子', '情绪定位']
@@ -31,15 +32,17 @@ export async function mechanicalCheck(ctx, { chapterNum, draftPath }) {
 
     const issues = []
     const candidates = []
+    // 待定稿批次叠加(spec §8.1 批内依赖):staged 条目/名册/信息差并入已知集合,只取本章之前的章
+    const staged = await stagedFacts(repoPath, { before: chapterNum })
 
     checkWordCount(body, bookConfig, issues) // 1
     checkBannedWords(body, style.禁词, issues) // 2
     checkBannedPatterns(body, style.禁句式, issues) // 3
     checkRepetition(body, issues) // 4
-    await checkNewProperNouns(body, cache, candidates) // 5(候选)
+    await checkNewProperNouns(body, cache, candidates, staged) // 5(候选)
     checkFrontMatter(parsed, fm, issues) // 6
-    await checkSecretKeywords(body, cache, candidates) // 7(候选)
-    await checkThreadDeclarations(fm, cache, issues) // 8(条目变动,只查形式)
+    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 基线指纹)
 
@@ -113,7 +116,7 @@ function checkRepetition(body, issues) {
 }
 
 // 保守启发式:对话提示词(道/说/问…)前的 2-3 字 Han 视作疑似人名,比对名册(非阻断候选)
-async function checkNewProperNouns(body, cache, candidates) {
+async function checkNewProperNouns(body, cache, candidates, staged) {
   const known = new Set()
   try {
     for (const e of await cache.query('SELECT id FROM entities')) known.add(e.id)
@@ -121,6 +124,8 @@ async function checkNewProperNouns(body, cache, candidates) {
   } catch {
     // 无缓存,跳过
   }
+  for (const n of staged?.newEntities || []) known.add(n)
+  for (const a of staged?.newAliases || []) known.add(a)
   const seen = new Set()
   const re = /([一-龥]{2,3})(冷笑道|笑道|喝道|说道|问道|答道|道|说|喊|问)/g
   let m
@@ -137,12 +142,17 @@ async function checkNewProperNouns(body, cache, candidates) {
   }
 }
 
-async function checkSecretKeywords(body, cache, candidates) {
+async function checkSecretKeywords(body, cache, candidates, staged) {
   let secrets = []
   try {
     secrets = await cache.query('SELECT id, keywords FROM secrets WHERE reader_knows = 0')
   } catch {
-    return
+    secrets = []
+  }
+  for (const s of staged?.secretWrites || []) {
+    const fm = s.frontMatter || {}
+    if (fm.读者已知 === true || fm.读者已知 === 'true') continue
+    secrets.push({ id: s.id, keywords: JSON.stringify(Array.isArray(fm.关键词) ? fm.关键词 : []) })
   }
   for (const s of secrets) {
     let kws = []
@@ -179,7 +189,7 @@ function checkFrontMatter(parsed, fm, issues) {
 
 // 条目变动形式检查(spec 0.9 §8 第 5 步;查 threads 表,零语义):
 // ①类型一致 ②开启类动词不得撞已有编号 ③非开启动词要求条目存在且状态=进行
-async function checkThreadDeclarations(fm, cache, issues) {
+async function checkThreadDeclarations(fm, cache, issues, staged) {
   const { declarations, malformed } = parseThreadDeclarations(fm)
   for (const bad of malformed) {
     issues.push({ check: '条目变动', severity: 'high', blocking: true, description: `条目声明格式应为「动词 编号」:${bad}` })
@@ -192,6 +202,10 @@ async function checkThreadDeclarations(fm, cache, issues) {
   } catch {
     return // 无缓存,跳过(形式检查依赖条目表)
   }
+  // 批内预登记的条目并入已知集合(K 章埋下、K+1 章推进不误判)
+  for (const [id, t] of staged?.threads || []) {
+    known.set(id, t.状态 ?? known.get(id) ?? '进行')
+  }
 
   for (const d of declarations) {
     if (!d.id.startsWith(`${d.type}-`)) {

+ 20 - 12
v7/src/prep/book-status.js

@@ -50,19 +50,27 @@ export async function assembleBookStatus(ctx) {
       悬了太久: overdue,
     }
 
-    const overdueLine = overdue.length
-      ? overdue.map((t) => `${t.id}(悬了 ${t.overdue_count} 章)`).join('、')
-      : '无'
-    const markdown = [
-      '## 全书近况(脚本生成)',
-      `- 位置:第 ${当前卷} 卷 ${data.卷内进度.写到}/${卷规模} 章`,
-      `- 悬了太久:${overdueLine}`,
-      `- 连续弱钩:${连续弱钩} 章`,
-      `- 全书:${data.总章数} 章 / ${data.总字数} 字 / ${data.条目数} 条目 / ${data.角色数} 角色`,
-    ].join('\n')
-
-    return { ok: true, data, markdown, error: '' }
+    return { ok: true, data, markdown: renderBookStatus(data), error: '' }
   } catch (err) {
     return { ok: false, data: null, markdown: '', error: `组装全书近况失败:${err.message}` }
   }
 }
+
+/**
+ * 全书近况 → Markdown(assembleBookStatus 与批次叠加视图共用同一渲染,不双写格式)。
+ * @param {object} data assembleBookStatus 的 data 形状
+ * @param {string[]} [extraLines] 追加在位置行之后的额外行(如批内暂存概况)
+ */
+export function renderBookStatus(data, extraLines = []) {
+  const overdueLine = data.悬了太久.length
+    ? data.悬了太久.map((t) => `${t.id}(悬了 ${t.overdue_count} 章)`).join('、')
+    : '无'
+  return [
+    '## 全书近况(脚本生成)',
+    `- 位置:第 ${data.当前卷} 卷 ${data.卷内进度.写到}/${data.卷内进度.卷规模} 章`,
+    ...extraLines,
+    `- 悬了太久:${overdueLine}`,
+    `- 连续弱钩:${data.连续弱钩} 章`,
+    `- 全书:${data.总章数} 章 / ${data.总字数} 字 / ${data.条目数} 条目 / ${data.角色数} 角色`,
+  ].join('\n')
+}

+ 52 - 13
v7/src/prep/index.js

@@ -5,10 +5,12 @@ import { extractSection } from '../util/markdown.js'
 import { TimelineReader } from '../storage/adapters/TimelineReader.js'
 import { SecretReader } from '../storage/adapters/SecretReader.js'
 import { ChapterReader } from '../storage/adapters/ChapterReader.js'
+import { stagedFacts, overlayBookStatus } from '../staging/index.js'
 
 /**
  * 备料:组装 工作区/本章写作材料.md(spec §8 step3,默认精准片段)。
  * 八组件:全书近况 + 要写到的事 + 事实切片 + 信息差边界 + 近章结尾 + 反复读清单 + 文风锚点 + 反和解规则。
+ * 有待定稿批次时按"定稿 + 批内预登记"叠加组装(spec §8.1,批内事实只取本章之前的章)。
  * @param {{repoPath: string, cache: object}} ctx
  * @param {{chapterNum: number}} args
  * @returns {Promise<{ok: boolean, filePath: string, content: string, error: string}>}
@@ -17,7 +19,8 @@ export async function prepareChapterMaterials(ctx, { chapterNum }) {
   try {
     const { repoPath, cache } = ctx
 
-    const status = await assembleBookStatus(ctx)
+    const facts = await stagedFacts(repoPath, { before: chapterNum })
+    const status = overlayBookStatus(await assembleBookStatus(ctx), facts)
     const 当前卷 = status.ok ? status.data.当前卷 : 1
 
     // 本章要写到的事(读细纲)
@@ -29,15 +32,19 @@ export async function prepareChapterMaterials(ctx, { chapterNum }) {
       // 无细纲
     }
 
-    // 事实切片:当前卷+上一卷时间线(精准片段)
+    // 事实切片:当前卷+上一卷时间线(精准片段)+ 批内预登记行
     const tl = await new TimelineReader(repoPath, cache).readVolumeRange(
       Math.max(1, 当前卷 - 1),
       当前卷
     )
-    const 时间线md =
+    const 时间线 =
       tl.ok && tl.timeline.length
-        ? tl.timeline.map((row) => `- ${row.章 ?? ''} ${row.一句话事件 ?? ''}`).join('\n')
-        : '(无)'
+        ? tl.timeline.map((row) => `- ${row.章 ?? ''} ${row.一句话事件 ?? ''}`)
+        : []
+    for (const tr of facts.timelineRows) {
+      时间线行.push(`- ${tr.row?.章 ?? ''} ${tr.row?.一句话事件 ?? ''}(批内预登记)`)
+    }
+    const 时间线md = 时间线行.length ? 时间线行.join('\n') : '(无)'
 
     // 信息差边界(未揭晓,勿泄):短题+知情人+关键词+内容首句——写稿 AI 知道秘密才守得住秘密
     const secretReader = new SecretReader(repoPath, cache)
@@ -51,17 +58,35 @@ export async function prepareChapterMaterials(ctx, { chapterNum }) {
         `- ${s.id}(${s.短题}):知情人=${知情人};关键词=${关键词};内容:${fl.line || '(未读到)'}——读者未知,除知情人的对话与视角外不得出现`
       )
     }
+    // 批内预登记的信息差(未揭晓)一并守住
+    for (const s of facts.secretWrites) {
+      const fm = s.frontMatter || {}
+      if (fm.读者已知 === true || fm.读者已知 === 'true') continue
+      const 知情人 = Array.isArray(fm.知情人) && fm.知情人.length ? fm.知情人.join('、') : '(未登记)'
+      const 关键词 = Array.isArray(fm.关键词) && fm.关键词.length ? fm.关键词.join('/') : '(无)'
+      信息差行.push(
+        `- ${s.id}(批内预登记):知情人=${知情人};关键词=${关键词};内容:${firstContentLine(s.content)}——读者未知,除知情人的对话与视角外不得出现`
+      )
+    }
     const 信息差md = 信息差行.length ? 信息差行.join('\n') : '(无)'
 
-    // 近章结尾(近 2 章末尾 150 字,反复读防接不上)
-    const recent = await cache.query(
-      'SELECT chapter_num FROM chapters ORDER BY chapter_num DESC LIMIT 2'
-    )
-    const reader = new ChapterReader(repoPath, cache)
+    // 近章结尾(近 2 章末尾 150 字,反复读防接不上):批内暂存章优先,不足回定稿章补
+    const stagedTail = facts.chapters.slice(-2)
     const tails = []
-    for (const r of recent.reverse()) {
-      const t = await reader.readTail(r.chapter_num, 150)
-      tails.push(`### 第${r.chapter_num}章结尾\n${t.ok ? t.text : ''}`)
+    const need = 2 - stagedTail.length
+    if (need > 0) {
+      const recent = await cache.query(
+        'SELECT chapter_num FROM chapters ORDER BY chapter_num DESC LIMIT ?',
+        [need]
+      )
+      const reader = new ChapterReader(repoPath, cache)
+      for (const r of recent.reverse()) {
+        const t = await reader.readTail(r.chapter_num, 150)
+        tails.push(`### 第${r.chapter_num}章结尾\n${t.ok ? t.text : ''}`)
+      }
+    }
+    for (const c of stagedTail) {
+      tails.push(`### 第${c.章号}章结尾(批内暂存)\n${charTail(c.body, 150)}`)
     }
 
     // 文风锚点 + 反和解(读文风铁律)
@@ -128,3 +153,17 @@ export async function prepareChapterMaterials(ctx, { chapterNum }) {
     return { ok: false, filePath: '', content: '', error: `备料失败:${err.message}` }
   }
 }
+
+// 批内暂存章的结尾片段(与 ChapterReader.readTail 同口径:正文末 N 个字符)
+function charTail(body, n) {
+  return [...String(body)].slice(-n).join('').trim()
+}
+
+// 信息差内容首句(跳过空行与小节标题)
+function firstContentLine(content) {
+  for (const line of String(content || '').split('\n')) {
+    const t = line.trim()
+    if (t && !t.startsWith('#')) return t
+  }
+  return '(未读到)'
+}

+ 47 - 2
v7/src/review/index.js

@@ -10,19 +10,24 @@ import { SecretReader } from '../storage/adapters/SecretReader.js'
 import { ThreadLedgerReader } from '../storage/adapters/ThreadLedgerReader.js'
 import { writeAtomicBatch } from '../storage/atomic.js'
 import { validateReviewReport } from './schema.js'
+import { stagedFacts, overlayBookStatus } from '../staging/index.js'
 
 const 兼容声明 = '本次使用兼容模式(单上下文顺序审稿),审稿隔离度低于完整两审模式。'
 const 完整声明 = '完整两审模式(事实审查/编辑审各自独立上下文)。'
 
+const 类型英文 = { 伏笔: 'foreshadow', 悬念: 'suspense', 感情线: 'romance' }
+
 /**
  * 组装两审共享的 ReviewInput DTO(AI 不见文件路径,实施计划 §1.5 原则 1)。
  * 复用 M1 读接口 + M2 全书近况 + 细纲「要写到的事」。
+ * 有待定稿批次时叠加批内预登记(只取本章之前的章)——批内第 K+1 章审稿能看到第 K 章事实。
  * @param {{repoPath, cache}} ctx
  * @param {{chapterNum: number, draftPath: string}} args
  */
 export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
   try {
     const { repoPath, cache } = ctx
+    const facts = await stagedFacts(repoPath, { before: chapterNum })
 
     // resolve 而非 join:draftPath 传绝对路径时 join 会拼出坏路径
     const 草稿全文 = await fs.readFile(path.resolve(repoPath, draftPath), 'utf8')
@@ -40,7 +45,7 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
       // 无细纲
     }
 
-    const status = await assembleBookStatus(ctx)
+    const status = overlayBookStatus(await assembleBookStatus(ctx), facts)
     const 当前卷 = status.ok ? status.data.当前卷 : 1
 
     // P1-1:名册快照(正名+别名),供 AI 判新专名,不泄漏路径;同时建 aliasMap 供角色别名命中
@@ -58,6 +63,17 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
     } catch {
       // 缓存不可用则略
     }
+    // 批内预登记的名册行(新角色/新别名)
+    for (const pair of facts.roster) {
+      const row = 名册.find((x) => x.正名 === pair.正名)
+      if (row) {
+        for (const a of pair.别名) if (!row.别名.includes(a)) row.别名.push(a)
+      } else {
+        名册.push({ 正名: pair.正名, 别名: [...pair.别名] })
+      }
+      const known = aliasMap.get(pair.正名) || []
+      aliasMap.set(pair.正名, [...new Set([...known, ...pair.别名])])
+    }
 
     // 相关角色:扫角色目录,正名或别名出现在草稿里的纳入(P1-1:别名也要命中)
     const 相关角色 = []
@@ -75,7 +91,10 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
         else if (typeof fmAliases === 'string')
           fmAliases.split(',').forEach((a) => a.trim() && aliases.add(a.trim()))
         const hit = 草稿全文.includes(name) || [...aliases].some((a) => a && 草稿全文.includes(a))
-        if (hit) 相关角色.push(cc.context)
+        if (!hit) continue
+        // 批内预登记的角色变更叠加(第 K 章改了状态,K+1 章审稿要看到最新值)
+        const upd = facts.characterUpdates.get(name)
+        相关角色.push(upd ? { ...cc.context, ...upd } : cc.context)
       }
     } catch {
       // 无角色目录
@@ -83,9 +102,17 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
 
     const tl = await new TimelineReader(repoPath, cache).readVolumeRange(Math.max(1, 当前卷 - 1), 当前卷)
     const 时间线片段 = tl.ok ? tl.timeline.map((row) => ({ 章: row.章 ?? '', 事件: row.一句话事件 ?? '' })) : []
+    for (const tr of facts.timelineRows) {
+      时间线片段.push({ 章: tr.row?.章 ?? '', 事件: `${tr.row?.一句话事件 ?? ''}(批内预登记)` })
+    }
 
     const secrets = await new SecretReader(repoPath, cache).listUnrevealed()
     const 信息差候选 = secrets.map((s) => ({ id: s.id, 短题: s.短题, 关键词: s.关键词 }))
+    for (const s of facts.secretWrites) {
+      const fm = s.frontMatter || {}
+      if (fm.读者已知 === true || fm.读者已知 === 'true') continue
+      信息差候选.push({ id: s.id, 短题: fm.短题 || s.id, 关键词: Array.isArray(fm.关键词) ? fm.关键词 : [] })
+    }
 
     // P1-1:相关条目 = 仍在进行、且在本章前开启的条目(不泄漏 file_path)
     let 相关条目 = []
@@ -105,6 +132,24 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
     } catch {
       // 缓存不可用则略
     }
+    // 批内预登记的条目事实:新开条目补进清单,已有条目刷状态/最后推进章
+    for (const [id, t] of facts.threads) {
+      const row = 相关条目.find((x) => x.id === id)
+      if (row) {
+        if (t.状态) row.状态 = t.状态
+        if (t.最后推进章) row.最后推进章 = t.最后推进章
+      } else if (t.新开) {
+        相关条目.push({
+          id,
+          type: 类型英文[t.type] || t.type,
+          简述: id,
+          状态: t.状态 || '进行',
+          开启章: t.开启章,
+          最后推进章: t.最后推进章 ?? t.开启章,
+          批内预登记: true,
+        })
+      }
+    }
 
     // 草稿声明涉及的条目附履历尾部(末 3 行);未声明的维持纯元数据(控 token)
     const declaredIds = new Set(拟条目变动.map((d) => d.id))

+ 70 - 4
v7/src/staging/index.js

@@ -13,6 +13,7 @@ import {
   SENTENCE_VARIANCE_TOLERANCE,
 } from '../style-stats/index.js'
 import { runHealthCheck } from '../health-check/index.js'
+import { renderBookStatus } from '../prep/book-status.js'
 
 /**
  * staging:待定稿批次(自动模式,spec §8.1)。批次真源 = 工作区/待定稿/ 下的文件,
@@ -105,8 +106,11 @@ function metaFile(rows) {
 /**
  * 叠加视图的事实包:staged 章正文/档案 + 预登记(条目/名册/角色/时间线/信息差)。
  * 全部来自批次文件,供备料/审稿输入/机检合并;无批次时 exists=false 且各集合为空。
+ * @param {string} repoPath
+ * @param {{before?: number}} [opts] 只取章号 < before 的 staged 章——组装第 K 章材料/审稿/机检时
+ *   批内事实只能来自更早的章(重审受影响章时,不许后章事实倒灌)
  */
-export async function stagedFacts(repoPath) {
+export async function stagedFacts(repoPath, opts = {}) {
   const batch = await readBatch(repoPath)
   const facts = {
     exists: batch.exists,
@@ -114,6 +118,7 @@ export async function stagedFacts(repoPath) {
     threads: new Map(),
     newEntities: new Set(),
     newAliases: new Set(),
+    roster: [],
     characterUpdates: new Map(),
     timelineRows: [],
     secretWrites: [],
@@ -122,7 +127,12 @@ export async function stagedFacts(repoPath) {
   }
   if (!batch.exists) return facts
 
-  for (const row of batch.章列表) {
+  const rows = Number.isInteger(opts.before)
+    ? batch.章列表.filter((r) => r.章号 < opts.before)
+    : batch.章列表
+  if (!rows.length) return { ...facts, exists: false }
+
+  for (const row of rows) {
     const dirP = path.join(repoPath, BATCH_DIR, row.目录)
 
     let fm = {}
@@ -182,8 +192,18 @@ export async function stagedFacts(repoPath) {
       facts.threads.set(t.id, cur)
     }
     for (const r of payload.rosterUpserts || []) {
-      if (r?.正名) facts.newEntities.add(r.正名)
-      for (const a of splitAliases(r?.别名)) facts.newAliases.add(a)
+      if (!r?.正名) continue
+      facts.newEntities.add(r.正名)
+      const aliases = splitAliases(r.别名)
+      for (const a of aliases) facts.newAliases.add(a)
+      const pair = facts.roster.find((x) => x.正名 === r.正名)
+      if (pair) {
+        for (const a of aliases) {
+          if (!pair.别名.includes(a)) pair.别名.push(a)
+        }
+      } else {
+        facts.roster.push({ 正名: r.正名, 别名: aliases })
+      }
     }
     for (const c of payload.characterUpdates || []) {
       if (!c?.name) continue
@@ -201,6 +221,52 @@ function splitAliases(v) {
   return []
 }
 
+/**
+ * 全书近况叠加(备料/审稿输入消费):staged 章计入位置与总量、连续弱钩接算、
+ * 批内已推进的条目从"悬了太久"剔除。assembleBookStatus 本体保持只算定稿。
+ * @param {{ok, data, markdown}} status assembleBookStatus 结果
+ * @param {object} facts stagedFacts 结果
+ */
+export function overlayBookStatus(status, facts) {
+  if (!status?.ok || !facts?.exists || !facts.chapters.length) return status
+  const staged = facts.chapters
+  const data = { ...status.data, 卷内进度: { ...status.data.卷内进度 } }
+
+  data.总章数 = status.data.总章数 + staged.length
+  data.总字数 = status.data.总字数 + facts.总字数
+
+  const 卷号 = (c) => Number(c.frontMatter.卷) || status.data.当前卷
+  const 新当前卷 = Math.max(status.data.当前卷, ...staged.map(卷号))
+  const 卷内新增 = staged.filter((c) => 卷号(c) === 新当前卷).length
+  if (新当前卷 !== status.data.当前卷) {
+    data.当前卷 = 新当前卷
+    data.卷内进度.写到 = 卷内新增
+  } else {
+    data.卷内进度.写到 = status.data.卷内进度.写到 + 卷内新增
+  }
+
+  let weak = 0
+  let broke = false
+  for (let i = staged.length - 1; i >= 0; i--) {
+    const h = String(staged[i].frontMatter.钩子 || '')
+    if (h.includes('弱钩') || h.endsWith('-弱')) weak++
+    else {
+      broke = true
+      break
+    }
+  }
+  data.连续弱钩 = broke ? weak : weak + status.data.连续弱钩
+
+  data.悬了太久 = status.data.悬了太久.filter((t) => !facts.threads.get(t.id)?.最后推进章)
+
+  const 起 = staged[0].章号
+  const 止 = staged[staged.length - 1].章号
+  const markdown = renderBookStatus(data, [
+    `- 批内已暂存:第 ${起}-${止} 章共 ${staged.length} 章(未定稿,作者批量过稿后入档)`,
+  ])
+  return { ...status, data, markdown }
+}
+
 /**
  * 暂存一章(自动模式的"第 7-8 步推迟"):校验 → 落批次目录 → 清本章工作区文件 → 停止判定。
  * 前置:本章两审已跑(工作区/审稿.md 在)——自动模式砍的是逐章问作者,不是逐章审。

+ 187 - 0
v7/test/staging/overlay.test.js

@@ -0,0 +1,187 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { stageChapter } from '../../src/staging/index.js'
+import { prepareChapterMaterials } from '../../src/prep/index.js'
+import { assembleReviewInput } from '../../src/review/index.js'
+import { mechanicalCheck } from '../../src/mechanical-check/index.js'
+import { repoCtx } from '../commands/_helper.js'
+
+// —— AC2 批内依赖:第 3 章预登记(新条目/新角色/时间线/信息差)→ 第 4 章备料/审稿输入/机检可见 ——
+
+const 定稿章 = (num) =>
+  `---\n章号: ${num}\n标题: 第${num}章\n卷: 1\n视角: 林晚\n书内时间: 春月初${num}\n字数: 100\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫\n---\n\n林晚在第${num}章遇到了新的麻烦,她收剑而立。`
+
+const 角色卡 = `---\n姓名: 林晚\n别名:\n  - 晚晚\n状态: 在世\n位置: 青云宗\n境界: 练气三层\n最后变更章: 1\n---\n## 设定\n外门弟子。\n\n## 典型对话\n"本姑娘才不怕!"\n\n## 关系\n无。\n`
+
+const bookFiles = () => ({
+  'book.yaml':
+    'spec_version: "7.0"\n书名: 叠加测试\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n连写批次大小: 8\n',
+  '定稿/正文/0001-第1章.md': 定稿章(1),
+  '定稿/正文/0002-第2章.md': 定稿章(2),
+  '定稿/设定/名册.md': '| 正名 | 别名 | 类型 | 首现章 |\n|--|--|--|--|\n| 林晚 | 晚晚 | character | 1 |\n',
+  '定稿/设定/角色/林晚.md': 角色卡,
+  '定稿/设定/时间线/第01卷.md':
+    '| 章 | 书内时间 | 一句话事件 | 在场 |\n|----|----|----|----|\n| 1 | 春月初一 | 遇袭 | 林晚 |\n| 2 | 春月初二 | 追查 | 林晚 |\n',
+  '大纲/卷纲/第01卷.md': '# 第01卷\n追查古钟之谜。\n',
+  '大纲/伏笔/伏笔-001-旧案.md': '---\n强度: 高\n状态: 进行\n开启章: 1\n最后推进章: 2\n---\n## 描述\n旧案。\n\n## 履历\n- 第1章:埋下\n',
+})
+
+async function stageChapter3(ctx) {
+  await fs.mkdir(path.join(ctx.repoPath, '工作区'), { recursive: true })
+  await fs.writeFile(
+    path.join(ctx.repoPath, '工作区', '审稿.md'),
+    '# 第 3 章审稿单\n\n> 完整两审模式。\n> 共 0 个问题:0 阻断。\n',
+    'utf8'
+  )
+  const payload = {
+    frontMatter: {
+      章号: 3,
+      标题: '闻钟',
+      卷: 1,
+      视角: '林晚',
+      书内时间: '春月初三',
+      字数: 120,
+      章定位: '推进',
+      钩子: '悬念钩-强',
+      情绪定位: '铺垫',
+      伏笔: ['埋下 伏笔-009', '推进 伏笔-001'],
+    },
+    body: '林晚夜里听见后山钟声,古钟长老现身拦路。她记下了钟声的方位,决定天亮再探。',
+    summary: '林晚闻钟遇古钟长老。',
+    threadCreates: [
+      {
+        id: '伏笔-009',
+        短题: '古钟',
+        frontMatter: { 强度: '中', 状态: '进行', 开启章: 3, 最后推进章: 3 },
+        body: '## 描述\n古钟的来历。\n\n## 履历\n- 第3章:埋下\n',
+      },
+    ],
+    threadUpdates: [{ id: '伏笔-001', updates: { 最后推进章: 3 }, history: '第3章:推进' }],
+    rosterUpserts: [{ 正名: '古钟长老', 别名: '钟叟', 类型: 'character', 首现章: 3 }],
+    characterUpdates: [{ name: '林晚', updates: { 最后变更章: 3, 境界: '练气四层' } }],
+    timelineRows: [
+      { volumeNum: 1, row: { 章: 3, 书内时间: '春月初三', 一句话事件: '闻钟遇长老', 在场: '林晚' } },
+    ],
+    secretWrites: [
+      {
+        id: '信息差-002-钟声',
+        frontMatter: { 读者已知: false, 登记章: 3, 短题: '钟声有古怪', 知情人: ['古钟长老'], 关键词: ['古钟'] },
+        content: '## 内容\n钟声是封印松动的征兆。\n',
+      },
+    ],
+    commitLines: {},
+    workspaceFiles: [],
+  }
+  const r = await stageChapter(ctx, { chapterNum: 3, payload })
+  assert.equal(r.ok, true, r.error)
+}
+
+test('AC2 备料叠加:第 4 章材料含批内近况/结尾/时间线/信息差', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles())
+  try {
+    await stageChapter3(ctx)
+    const r = await prepareChapterMaterials(ctx, { chapterNum: 4 })
+    assert.equal(r.ok, true, r.error)
+    assert.match(r.content, /批内已暂存:第 3-3 章共 1 章/)
+    assert.match(r.content, /### 第3章结尾(批内暂存)\n[\s\S]*天亮再探/)
+    assert.match(r.content, /### 第2章结尾/) // 不足两章回定稿补
+    assert.match(r.content, /- 3 闻钟遇长老(批内预登记)/)
+    assert.match(r.content, /- 信息差-002-钟声(批内预登记):知情人=古钟长老;关键词=古钟;内容:钟声是封印松动的征兆。/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('AC2 审稿输入叠加:第 4 章相关条目/名册/角色/时间线/信息差看到第 3 章预登记', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles())
+  try {
+    await stageChapter3(ctx)
+    const draftPath = path.join(ctx.repoPath, '工作区', '草稿-A.md')
+    await fs.writeFile(
+      draftPath,
+      '---\n章号: 4\n标题: 探钟\n卷: 1\n字数: 60\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫\n伏笔:\n  - 推进 伏笔-009\n---\n林晚天亮后去探古钟,山道上又见古钟长老。',
+      'utf8'
+    )
+    const r = await assembleReviewInput(ctx, { chapterNum: 4, draftPath })
+    assert.equal(r.ok, true, r.error)
+    const t = r.input.相关条目.find((x) => x.id === '伏笔-009')
+    assert.ok(t, JSON.stringify(r.input.相关条目))
+    assert.equal(t.批内预登记, true)
+    assert.equal(t.开启章, 3)
+    const old = r.input.相关条目.find((x) => x.id === '伏笔-001')
+    assert.equal(old.最后推进章, 3, '批内推进要刷进相关条目')
+    assert.ok(r.input.名册.some((x) => x.正名 === '古钟长老' && x.别名.includes('钟叟')))
+    const 林晚 = r.input.相关角色.find((x) => x.正名 === '林晚')
+    assert.ok(林晚, '草稿提到林晚应命中角色')
+    assert.equal(林晚.境界, '练气四层', '批内角色变更要叠加')
+    assert.ok(r.input.时间线片段.some((x) => String(x.事件).includes('批内预登记')))
+    assert.ok(r.input.信息差候选.some((x) => x.id === '信息差-002-钟声'))
+    assert.match(r.input.全书近况, /批内已暂存/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('重审受影响章不倒灌:第 3 章自己的审稿输入看不到第 3 章预登记', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles())
+  try {
+    await stageChapter3(ctx)
+    const draftPath = path.join(ctx.repoPath, '工作区', '草稿-A.md')
+    await fs.writeFile(
+      draftPath,
+      '---\n章号: 3\n标题: 闻钟\n卷: 1\n字数: 40\n章定位: 推进\n钩子: 悬念钩-强\n情绪定位: 铺垫\n---\n林晚夜里听见后山钟声。',
+      'utf8'
+    )
+    const r = await assembleReviewInput(ctx, { chapterNum: 3, draftPath })
+    assert.equal(r.ok, true, r.error)
+    assert.ok(!r.input.相关条目.some((x) => x.id === '伏笔-009'))
+    assert.ok(!r.input.名册.some((x) => x.正名 === '古钟长老'))
+    assert.doesNotMatch(r.input.全书近况, /批内已暂存/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('AC2 机检叠加:推进批内新条目零误报、批内新角色不报新专名、批内信息差出候选', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles())
+  try {
+    await stageChapter3(ctx)
+    const draftPath = path.join(ctx.repoPath, '工作区', '草稿-B.md')
+    await fs.writeFile(
+      draftPath,
+      '---\n章号: 4\n标题: 探钟\n卷: 1\n字数: 3000\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫\n伏笔:\n  - 推进 伏笔-009\n---\n' +
+        '古钟长老道:“你不该来。”林晚握紧短刀盯着古钟出神,山风吹动她的衣角。'.repeat(1) +
+        '她沿着山道慢慢往上走,一步一步踩着碎石,心里盘算着夜里听到的那阵钟声到底从哪里来。'.repeat(1),
+      'utf8'
+    )
+    const r = await mechanicalCheck(ctx, { chapterNum: 4, draftPath })
+    assert.equal(r.ok, true, r.error)
+    assert.deepEqual(
+      r.issues.filter((i) => i.check === '条目变动'),
+      [],
+      JSON.stringify(r.issues)
+    )
+    assert.ok(!r.candidates.some((c) => c.type === '新专名' && c.value === '古钟长老'), JSON.stringify(r.candidates))
+    assert.ok(r.candidates.some((c) => c.type === '信息差候选' && c.value === '信息差-002-钟声'), JSON.stringify(r.candidates))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('AC7 缓存不变量:批次进行中删缓存全量重建,备料输出不变', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles())
+  try {
+    await stageChapter3(ctx)
+    const before = await prepareChapterMaterials(ctx, { chapterNum: 4 })
+    assert.equal(before.ok, true, before.error)
+    const rb = await ctx.cache.rebuildFromSource(ctx.repoPath)
+    assert.equal(rb.ok, true, rb.errors?.join(';'))
+    const after = await prepareChapterMaterials(ctx, { chapterNum: 4 })
+    assert.equal(after.ok, true, after.error)
+    assert.equal(after.content, before.content)
+  } finally {
+    await cleanup()
+  }
+})