Browse Source

feat(v7): 建立章级知识选择闭环

lingfengQAQ 3 tháng trước cách đây
mục cha
commit
5713a1b0c6

+ 7 - 7
.trellis/tasks/07-10-knowledge-runtime-framework/implement.md

@@ -16,7 +16,7 @@
 - [x] 1.2 扩展书级 canonical 归一到 `类型`、`副题材`、`流派`,兼容题材只提醒、不替作者删选项。
 - [x] 1.3 为条目读取增加规划/落笔/审稿切片和 `路径@sha256` 来源版本。
 - [x] 1.4 实现有限候选查询和 `knowledge-query`;每维默认最多 3 条,空结果可自定义继续。
-- [ ] 1.5 删除全量章级索引的运行时出口,补 canonical 不等于创意选择、缺目录降级和候选限量测试。
+- [x] 1.5 删除全量章级索引的运行时出口,补 canonical 不等于创意选择、缺目录降级和候选限量测试。
 - [x] 1.6 定向运行 knowledge、references 和 DTO 测试后提交“知识内核”单元(`c6a0146`)。
 
 ## 阶段 2:作品契约唯一真源
@@ -43,12 +43,12 @@
 
 ## 阶段 4:章级少量候选与精准切片
 
-- [ ] 4.1 draft-outline DTO 改为按本章语料查询四维少量候选,不再发送全量菜单。
-- [ ] 4.2 一次性切换细纲声明到节拍/场景/技法/追读/变体/对象,并允许多选与自定义。
-- [ ] 4.3 prepare 只注入所选落笔切片,review 只注入所选审稿切片;规划切片确有消费者。
-- [ ] 4.4 手动 finalize 与 stage-chapter 在清理细纲前固化 `知识选择`,finalize-batch 保持同构。
-- [ ] 4.5 从近期定稿章和批内前章读取历史,只做降权与重复提醒,合法递进不硬禁。
-- [ ] 4.6 补候选限量、整体确认、自定义降级、精准切片、近期软信号和手动/批量一致性测试后提交“章级调用”单元。
+- [x] 4.1 draft-outline DTO 改为按本章语料查询四维少量候选,不再发送全量菜单。
+- [x] 4.2 一次性切换细纲声明到节拍/场景/技法/追读/变体/对象,并允许多选与自定义。
+- [x] 4.3 prepare 只注入所选落笔切片,review 只注入所选审稿切片;规划切片确有消费者。
+- [x] 4.4 手动 finalize 与 stage-chapter 在清理细纲前固化 `知识选择`,finalize-batch 保持同构。
+- [x] 4.5 从近期定稿章和批内前章读取历史,只做降权与重复提醒,合法递进不硬禁。
+- [x] 4.6 补候选限量、整体确认、自定义降级、精准切片、近期软信号和手动/批量一致性测试后提交“章级调用”单元。
 
 ## 阶段 5:分发与文档一次性切换
 

+ 8 - 1
v7/src/finalize/index.js

@@ -11,6 +11,7 @@ import { refreshCacheAfterSourceChange } from '../cache/index.js'
 import { normalizeWorkspaceRel } from '../util/workspace-path.js'
 import { validateFactChanges } from '../knowledge/fact-changes.js'
 import { sanitizeFileName } from '../util/filename.js'
+import { archiveChapterKnowledgeFromOutline } from '../knowledge/chapter.js'
 
 /**
  * 定稿:原子 commit(D3)。写工作树 → git add → commit → 最后清工作区。
@@ -19,12 +20,18 @@ import { sanitizeFileName } from '../util/filename.js'
  *
  * @param {{repoPath: string, cache?: object}} ctx
  * @param {object} payload 定稿包(章档案/正文/摘要/条目/设定变更/commit 行/待清工作区文件)
- * @param {{git?: object, faultAfterWrite?: boolean}} [opts] 注入 git(测试)/故障注入(断电模拟)
+ * @param {{git?: object, faultAfterWrite?: boolean, archiveOutline?: boolean}} [opts]
+ *   注入 git(测试)/故障注入(断电模拟);批量转正传 archiveOutline=false,只消费暂存值
  * @returns {Promise<{ok: boolean, commitHash?: string, error?: string}>}
  */
 export async function finalizeChapter(ctx, payload, opts = {}) {
   const { repoPath } = ctx
   const git = opts.git || createGit(repoPath)
+  if (opts.archiveOutline !== false) {
+    const archived = await archiveChapterKnowledgeFromOutline(ctx, payload)
+    if (!archived.ok) return { ok: false, error: `定稿停止:${archived.error}` }
+    payload = archived.payload
+  }
   const {
     chapterNum,
     frontMatter,

+ 222 - 0
v7/src/knowledge/chapter.js

@@ -0,0 +1,222 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseFrontMatter } from '../storage/parsers/front-matter.js'
+import { KNOWLEDGE_GROUPS, readEntry } from './index.js'
+
+export const CHAPTER_KNOWLEDGE_DIMENSIONS = KNOWLEDGE_GROUPS.篇章执行
+export const RECENT_KNOWLEDGE_CHAPTER_LIMIT = 8
+
+export const CHAPTER_KNOWLEDGE_LABELS = Object.freeze({
+  节拍: '本章节拍',
+  场景: '本章场景',
+  技法: '本章技法',
+  追读: '本章追读',
+})
+
+const LABEL_TO_KEY = Object.freeze({
+  本章节拍: '节拍',
+  本章场景: '场景',
+  本章技法: '技法',
+  本章追读: '追读',
+  知识变体: '变体',
+  本章对象: '对象',
+})
+
+/** 确认细纲的六类声明位;四维知识都可空、可多选,变体按完整行保留。 */
+export function parseChapterDeclarations(outline) {
+  const out = { 节拍: [], 场景: [], 技法: [], 追读: [], 变体: [], 对象: [] }
+  for (const line of String(outline || '').split(/\r?\n/)) {
+    const match = line.trim().match(
+      /^(本章节拍|本章场景|本章技法|本章追读|知识变体|本章对象)[::]\s*(.*)$/
+    )
+    if (!match || !match[2].trim()) continue
+    const key = LABEL_TO_KEY[match[1]]
+    const values = key === '变体' ? [match[2].trim()] : normalizeList(match[2])
+    for (const value of values) {
+      if (!out[key].includes(value)) out[key].push(value)
+    }
+  }
+  return out
+}
+
+/** 把细纲声明解析为已命中条目或自定义选择;匹配只认精确名称/编号。 */
+export async function resolveChapterDeclarations(packageRoot, outlineOrDeclarations) {
+  const declarations = typeof outlineOrDeclarations === 'string'
+    ? parseChapterDeclarations(outlineOrDeclarations)
+    : outlineOrDeclarations || parseChapterDeclarations('')
+  const selections = []
+  for (const dimension of CHAPTER_KNOWLEDGE_DIMENSIONS) {
+    const declarationsForDimension = declarations?.[dimension] || []
+    if (!declarationsForDimension.length) continue
+    const files = packageRoot ? await listDimensionFiles(packageRoot, dimension) : []
+    for (const declaration of declarationsForDimension) {
+      selections.push({
+        维度: dimension,
+        声明: declaration,
+        条目: packageRoot
+          ? await readDeclaredEntry(packageRoot, dimension, files, declaration)
+          : null,
+      })
+    }
+  }
+  return { declarations, selections }
+}
+
+/** 章档案使用平铺列表:`维度|名称` 与真实发生的 `变体|说明`。 */
+export async function buildChapterKnowledgeArchive(packageRoot, outline) {
+  const { declarations, selections } = await resolveChapterDeclarations(packageRoot, outline)
+  const archived = []
+  for (const selection of selections) {
+    const name = selection.条目?.名称 || selection.声明
+    pushUnique(archived, `${selection.维度}|${name}`)
+  }
+  for (const variant of declarations.变体) pushUnique(archived, `变体|${variant}`)
+  return archived
+}
+
+/** 解析定稿章的最小历史,供候选软降权与重复提醒使用。 */
+export function parseChapterKnowledgeArchive(value, { chapterNum } = {}) {
+  if (!Array.isArray(value)) return []
+  const selections = []
+  const variants = []
+  for (const raw of value) {
+    const text = typeof raw === 'string' ? raw.trim() : ''
+    const split = text.indexOf('|')
+    if (split <= 0 || split === text.length - 1) continue
+    const dimension = text.slice(0, split).trim()
+    const name = text.slice(split + 1).trim()
+    if (dimension === '变体') variants.push(name)
+    else if (CHAPTER_KNOWLEDGE_DIMENSIONS.includes(dimension)) {
+      selections.push({ 维度: dimension, 名称: name })
+    }
+  }
+  const variant = variants.join(';')
+  return selections.map((selection) => ({
+    ...selection,
+    ...(Number.isInteger(chapterNum) ? { 章号: chapterNum } : {}),
+    ...(variant ? { 变体: variant } : {}),
+  }))
+}
+
+/** 最近若干章的选择历史;stagedChapters 覆盖同章定稿视图并参与统一倒序截断。 */
+export async function readRecentChapterKnowledge(
+  repoPath,
+  { before = Number.POSITIVE_INFINITY, limit = RECENT_KNOWLEDGE_CHAPTER_LIMIT, stagedChapters = [] } = {}
+) {
+  const chapters = new Map()
+  for (const chapter of Array.isArray(stagedChapters) ? stagedChapters : []) {
+    if (!Number.isInteger(chapter?.章号) || chapter.章号 >= before) continue
+    chapters.set(chapter.章号, { frontMatter: chapter.frontMatter || {} })
+  }
+
+  const dir = path.join(repoPath, '定稿', '正文')
+  try {
+    const files = (await fs.readdir(dir))
+      .filter((file) => /^\d+-.*\.md$/.test(file))
+      .sort(compareCodePoints)
+    for (const file of files) {
+      const chapterNum = Number(file.match(/^(\d+)-/)[1])
+      if (!Number.isInteger(chapterNum) || chapterNum >= before || chapters.has(chapterNum)) continue
+      chapters.set(chapterNum, { filePath: path.join(dir, file) })
+    }
+  } catch {
+    // 新书尚无定稿目录时,近期历史自然为空。
+  }
+
+  const recent = [...chapters.entries()]
+    .sort((a, b) => b[0] - a[0])
+    .slice(0, Math.max(0, Number.isInteger(limit) ? limit : RECENT_KNOWLEDGE_CHAPTER_LIMIT))
+  const history = []
+  for (const [chapterNum, source] of recent) {
+    let frontMatter = source.frontMatter
+    if (!frontMatter) {
+      try {
+        const parsed = parseFrontMatter(await fs.readFile(source.filePath, 'utf8'))
+        if (!parsed.ok) continue
+        frontMatter = parsed.data
+      } catch {
+        continue
+      }
+    }
+    history.push(...parseChapterKnowledgeArchive(frontMatter?.知识选择, { chapterNum }))
+  }
+  return history
+}
+
+/** 有确认细纲时以其覆盖 payload 里的知识选择;批量定稿无细纲时保留暂存值。 */
+export async function archiveChapterKnowledgeFromOutline(ctx, payload) {
+  let outline
+  try {
+    outline = await fs.readFile(path.join(ctx.repoPath, '工作区', '细纲.md'), 'utf8')
+  } catch (err) {
+    if (err.code === 'ENOENT') return { ok: true, payload, found: false, error: '' }
+    return { ok: false, payload, found: false, error: `确认细纲读取失败:${err.message}` }
+  }
+
+  try {
+    const frontMatter = { ...(payload?.frontMatter || {}) }
+    const archive = await buildChapterKnowledgeArchive(ctx.packageRoot, outline)
+    if (archive.length) frontMatter.知识选择 = archive
+    else delete frontMatter.知识选择
+    return {
+      ok: true,
+      payload: { ...payload, frontMatter },
+      found: true,
+      error: '',
+    }
+  } catch (err) {
+    return { ok: false, payload, found: true, error: `确认细纲知识解析失败:${err.message}` }
+  }
+}
+
+async function listDimensionFiles(packageRoot, dimension) {
+  try {
+    return (await fs.readdir(path.join(packageRoot, 'references', dimension), { withFileTypes: true }))
+      .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
+      .map((entry) => entry.name)
+      .sort(compareCodePoints)
+  } catch {
+    return []
+  }
+}
+
+async function readDeclaredEntry(packageRoot, dimension, files, declaration) {
+  const value = String(declaration || '').trim()
+  if (!value) return null
+  const id = dimension === '节拍' ? value.split(/[\s ]/)[0] : ''
+  const candidateFiles = files.filter((file) => {
+    const stem = file.slice(0, -3)
+    return stem === value || stem.endsWith(`-${value}`) ||
+      (id && (stem === id || stem.startsWith(`${id}-`)))
+  })
+  for (const file of candidateFiles) {
+    const entry = await readEntry(packageRoot, `${dimension}/${file}`)
+    if (!entry) continue
+    const name = String(entry.fm.名称 || '').trim()
+    const entryId = String(entry.fm.编号 || '').trim()
+    if (value === name || (entryId && (
+      value === entryId || value === `${entryId} ${name}` ||
+      value.startsWith(`${entryId} `) || value.startsWith(`${entryId} `)
+    ))) {
+      return { ...entry, 名称: name, ...(entryId ? { 编号: entryId } : {}) }
+    }
+  }
+  return null
+}
+
+function normalizeList(value) {
+  const out = []
+  for (const item of String(value || '').split(/[、,,]/)) {
+    const text = item.trim()
+    if (text && !out.includes(text)) out.push(text)
+  }
+  return out
+}
+
+function pushUnique(values, value) {
+  if (value && !values.includes(value)) values.push(value)
+}
+
+function compareCodePoints(a, b) {
+  return a < b ? -1 : a > b ? 1 : 0
+}

+ 0 - 63
v7/src/knowledge/index.js

@@ -156,16 +156,6 @@ export async function resolveBookKnowledge(
   return { 题材命中, 副题材命中, 流派命中, 未命中, 兼容提醒 }
 }
 
-/** 旧调用面在一次性切换期间的内部桥接;最终调用者改完后删除。 */
-export async function resolveBookTags(packageRoot, { 类型, 流派 = [] }) {
-  const result = await resolveBookKnowledge(packageRoot, { 类型, 流派 })
-  return {
-    题材命中: result.题材命中,
-    流派命中: result.流派命中,
-    未命中: result.未命中.map((item) => item.输入),
-  }
-}
-
 async function withRouteContent(packageRoot, row) {
   const out = {
     名称: row.名称,
@@ -291,59 +281,6 @@ export async function queryKnowledge(
   }))
 }
 
-/** 章级旧索引调用面;由十维正式读取器提供数据。 */
-export async function listChapterIndex(packageRoot, dimension) {
-  if (!KNOWLEDGE_GROUPS.篇章执行.includes(dimension)) return []
-  const entries = await listKnowledgeEntries(packageRoot, dimension)
-  return entries.map((entry) => ({
-    名称: entry.名称,
-    编号: entry.编号 || '',
-    关键词: entry.关键词 || [],
-    一句话: entry.一句话 || '',
-    文件: entry.文件,
-  }))
-}
-
-/** 按编号或名称精确读取已声明知识;自定义声明返回 null。 */
-export async function findDeclared(packageRoot, dimension, declaration) {
-  const decl = String(declaration || '').trim()
-  if (!decl) return null
-  const entries = await listKnowledgeEntries(packageRoot, dimension)
-  const hit = entries.find(
-    (entry) =>
-      (entry.编号 && decl.startsWith(entry.编号)) ||
-      decl === entry.名称 ||
-      decl.includes(entry.名称)
-  )
-  return hit ? readEntry(packageRoot, hit.文件) : null
-}
-
-/** 场景候选保留为薄封装,实际走同一个有限候选查询器。 */
-export async function sceneCandidates(packageRoot, texts) {
-  const corpus = (Array.isArray(texts) ? texts : [texts]).filter(Boolean).join('\n')
-  if (!corpus) return []
-  const hits = await queryKnowledge(packageRoot, '场景', { 问题: corpus })
-  return hits.map((entry) => ({ 名称: entry.名称, 一句话: entry.一句话 || '' }))
-}
-
-/**
- * 旧细纲声明解析器在章级调用切换前保留;下一阶段一次性改为十维目标标签。
- */
-export function parseOutlineDeclarations(outline) {
-  const out = { 节拍: '', 钩子: '', 场景: [], 对象: [] }
-  for (const line of String(outline || '').split('\n')) {
-    const text = line.trim()
-    const match = text.match(/^(本章节拍|章尾钩子|本章场景|本章对象)[::]\s*(.*)$/)
-    if (!match || !match[2]) continue
-    const value = match[2].trim()
-    if (match[1] === '本章节拍') out.节拍 = value
-    else if (match[1] === '章尾钩子') out.钩子 = value
-    else if (match[1] === '本章场景') out.场景 = normalizeList(value)
-    else out.对象 = normalizeList(value)
-  }
-  return out
-}
-
 function adaptIndexFields(dimension, fm) {
   const base = {
     名称: String(fm.名称),

+ 19 - 14
v7/src/prep/index.js

@@ -7,9 +7,13 @@ import { SecretReader } from '../storage/adapters/SecretReader.js'
 import { ChapterReader } from '../storage/adapters/ChapterReader.js'
 import { stagedFacts, overlayBookStatus } from '../staging/index.js'
 import { parseFrontMatter } from '../storage/parsers/front-matter.js'
-import { parseOutlineDeclarations, findDeclared } from '../knowledge/index.js'
 import { ContractReader } from '../storage/adapters/ContractReader.js'
 import { DesignReader } from '../storage/adapters/DesignReader.js'
+import {
+  CHAPTER_KNOWLEDGE_LABELS,
+  parseChapterDeclarations,
+  resolveChapterDeclarations,
+} from '../knowledge/chapter.js'
 
 /**
  * 备料:组装 工作区/本章写作材料.md(spec §8 step3,默认精准片段)。
@@ -112,7 +116,7 @@ export async function prepareChapterMaterials(ctx, { chapterNum }) {
 
     const 契约段 = `## 作品契约(本章所需)\n${contract.content.replace(/^## /gm, '### ')}`
 
-    const declarations = parseOutlineDeclarations(outlineText)
+    const declarations = parseChapterDeclarations(outlineText)
     const designObjects = await assembleDesignObjects(
       repoPath,
       declarations.对象,
@@ -124,7 +128,7 @@ export async function prepareChapterMaterials(ctx, { chapterNum }) {
     const stagedFactObjects = assembleStagedFactObjects(facts.factChanges)
 
     // 细纲声明命中的知识切片(声明即路由;查不到表注入声明文本本身,spec §7)
-    const 知识切片 = ctx.packageRoot ? await declaredKnowledge(ctx.packageRoot, outlineText) : ''
+    const 知识切片 = await declaredKnowledge(ctx.packageRoot, outlineText)
 
     // 反复读清单:体检产出的跨章高频意象(meta imagery_top),提醒本章避免再用
     let 反复读 = '(尚未体检,暂无数据——首次体检后自动填充)'
@@ -228,22 +232,23 @@ function charTail(body, n) {
   return [...String(body)].slice(-n).join('').trim()
 }
 
-/** 细纲声明位 → 知识切片段(节拍/钩子/场景的「落笔时」节;未命中降级为声明文本本身) */
+/** 确认细纲的四维选择 → 落笔切片;自定义与真实变体保留原文。 */
 async function declaredKnowledge(packageRoot, outlineText) {
-  const decl = parseOutlineDeclarations(outlineText)
+  const { declarations, selections } = await resolveChapterDeclarations(packageRoot, outlineText)
   const parts = []
-  const push = async (label, dir, declaration) => {
-    if (!declaration) return
-    const entry = await findDeclared(packageRoot, dir, declaration)
-    if (entry && entry.落笔时) {
-      parts.push(`### ${label}:${declaration}\n${entry.落笔时}`)
+  for (const selection of selections) {
+    const label = CHAPTER_KNOWLEDGE_LABELS[selection.维度]
+    if (selection.条目?.落笔时) {
+      parts.push(`### ${label}:${selection.声明}\n${selection.条目.落笔时}`)
+    } else if (selection.条目) {
+      parts.push(`### ${label}:${selection.声明}\n(该条目没有额外落笔切片,按细纲声明执行)`)
     } else {
-      parts.push(`### ${label}:${declaration}\n(知识库无此条目,按声明执行)`)
+      parts.push(`### ${label}:${selection.声明}\n(自定义选择,按细纲声明执行)`)
     }
   }
-  await push('本章节拍', '节拍', decl.节拍)
-  await push('章尾钩子', '追读', decl.钩子)
-  for (const s of decl.场景) await push('本章场景', '场景', s)
+  if (declarations.变体.length) {
+    parts.push(`### 知识变体\n${declarations.变体.map((item) => `- ${item}`).join('\n')}`)
+  }
   return parts.length ? `## 本章知识切片(按细纲声明注入)\n\n${parts.join('\n\n')}` : ''
 }
 

+ 14 - 14
v7/src/review/index.js

@@ -12,9 +12,12 @@ import { writeAtomicBatch } from '../storage/atomic.js'
 import { validateReviewReport } from './schema.js'
 import { stagedFacts, overlayBookStatus } from '../staging/index.js'
 import { ContractReader } from '../storage/adapters/ContractReader.js'
-import { parseOutlineDeclarations, findDeclared } from '../knowledge/index.js'
 import { reviewOutcomeFile } from './outcome.js'
 import { DesignReader } from '../storage/adapters/DesignReader.js'
+import {
+  parseChapterDeclarations,
+  resolveChapterDeclarations,
+} from '../knowledge/chapter.js'
 
 const 兼容声明 = '本次使用兼容模式(单上下文顺序审稿),审稿隔离度低于完整两审模式。'
 const 完整声明 = '完整两审模式(事实审查/编辑审各自独立上下文)。'
@@ -26,21 +29,18 @@ const 类型英文 = { 伏笔: 'foreshadow', 悬念: 'suspense', 感情线: 'rom
  */
 async function assembleKnowledgeChecks(ctx, outlineText) {
   const out = []
-  if (!ctx.packageRoot) return out
   try {
-    const decl = parseOutlineDeclarations(outlineText)
-    const hits = [
-      ['节拍', decl.节拍 ? [decl.节拍] : []],
-      ['追读', decl.钩子 ? [decl.钩子] : []],
-      ['场景', decl.场景],
-    ]
-    for (const [dir, decls] of hits) {
-      for (const d of decls) {
-        const entry = await findDeclared(ctx.packageRoot, dir, d)
-        if (!entry) continue
-        if (entry.审稿时) out.push(`【${entry.fm.名称 || d}·核对】${entry.审稿时.replace(/\n+/g, ' ')}`)
+    const { declarations, selections } = await resolveChapterDeclarations(ctx.packageRoot, outlineText)
+    for (const selection of selections) {
+      if (selection.条目?.审稿时) {
+        out.push(`【${selection.条目.名称}·核对】${selection.条目.审稿时.replace(/\n+/g, ' ')}`)
+      } else if (selection.条目) {
+        out.push(`【${selection.条目.名称}·核对】按确认细纲中的${selection.维度}选择核对。`)
+      } else {
+        out.push(`【${selection.维度}·自定义】按确认细纲核对:${selection.声明}`)
       }
     }
+    for (const variant of declarations.变体) out.push(`【知识变体】按确认细纲核对:${variant}`)
   } catch {
     // 知识库不可用则略
   }
@@ -81,7 +81,7 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
     }
 
     const 知识审查 = await assembleKnowledgeChecks(ctx, outlineText)
-    const designDeclarations = parseOutlineDeclarations(outlineText).对象
+    const designDeclarations = parseChapterDeclarations(outlineText).对象
     const designObjects = designDeclarations.length
       ? await new DesignReader(repoPath).readMany(designDeclarations, {
           excludePaths: facts.removedPlans || new Set(),

+ 6 - 1
v7/src/staging/index.js

@@ -25,6 +25,7 @@ import {
 } from '../knowledge/contract-issues.js'
 import { isFactPath, validateFactChanges } from '../knowledge/fact-changes.js'
 import { isDesignPath } from '../knowledge/design.js'
+import { archiveChapterKnowledgeFromOutline } from '../knowledge/chapter.js'
 
 /**
  * staging:待定稿批次(自动模式,spec §8.1)。批次真源 = 工作区/待定稿/ 下的文件,
@@ -347,6 +348,10 @@ export async function stageChapter(ctx, { chapterNum, payload }) {
     if (!reviewed.ok) return reviewed
     payload = reviewed.payload
 
+    const archived = await archiveChapterKnowledgeFromOutline(ctx, payload)
+    if (!archived.ok) return { ok: false, error: `暂存停止:${archived.error}` }
+    payload = archived.payload
+
     const priorFacts = await stagedFacts(repoPath, { before: chapterNum })
     const factValidation = await validateFactChanges(repoPath, payload.factChanges, {
       factOverlay: priorFacts.factChanges,
@@ -748,7 +753,7 @@ export async function finalizeBatch(ctx, { until } = {}) {
         }
         // 本章工作区文件在暂存时已清;批次目录由本函数自管,防误删他章工件
         payload.workspaceFiles = []
-        const r = await finalizeChapter(ctx, payload)
+        const r = await finalizeChapter(ctx, payload, { archiveOutline: false })
         if (!r.ok) {
           return {
             ok: false,

+ 76 - 26
v7/src/state-machine/dto.js

@@ -2,8 +2,12 @@ import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { assembleBookStatus } from '../prep/book-status.js'
 import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
-import { readBatch, judgeStop, 章状态 } from '../staging/index.js'
-import { loadRoutes, listChapterIndex, sceneCandidates } from '../knowledge/index.js'
+import { readBatch, stagedFacts, judgeStop, 章状态 } from '../staging/index.js'
+import { loadRoutes, queryKnowledge } from '../knowledge/index.js'
+import {
+  CHAPTER_KNOWLEDGE_DIMENSIONS,
+  readRecentChapterKnowledge,
+} from '../knowledge/chapter.js'
 import { OutlineReader } from '../storage/adapters/OutlineReader.js'
 import { ChapterReader } from '../storage/adapters/ChapterReader.js'
 import {
@@ -79,6 +83,7 @@ export async function buildDto(ctx, 序, base = {}) {
       const 知识 = await chapterKnowledge(ctx, {
         当前卷: status.ok ? status.data.当前卷 : 1,
         nextChapter: base.nextChapter,
+        本章任务: base.本章任务,
       })
       const contractSignals = evaluateContractIssueSignals(
         await readContractIssueHistory(ctx.repoPath),
@@ -95,7 +100,7 @@ export async function buildDto(ctx, 序, base = {}) {
           自动确认细纲
             ? '工作区/细纲.md(含本章定位声明 + 本章要写到的事 + 备选,由 M3 落盘);自动确认细纲已开:提案直接 persist-outline 生效,不再问作者;卷近尾声时提案可含收卷提议'
             : '工作区/细纲.md(含本章定位声明 + 本章要写到的事 + 备选,由 M3 落盘);卷近尾声时提案可含收卷提议(依据卷纲进度与卷规模参考值,作者确认后定稿写入 收卷: 是)'
-        };本章提案段内按需声明知识位(皆可空,从菜单点选或自定义):本章节拍:<编号或名称> / 章尾钩子:<类型> / 本章场景:<顿号分隔多值>`,
+        };章级候选只是少量材料,不是固定答案。可组合、变体或完全自定义,作者确认整份细纲即确认这些软策略。提案段按需声明(皆可空、多选):本章节拍 / 本章场景 / 本章技法 / 本章追读;实际修改或自定义另写知识变体,本章涉及计划对象时写本章对象。`,
       }
     }
     default:
@@ -104,39 +109,84 @@ export async function buildDto(ctx, 序, base = {}) {
 }
 
 /**
- * 序6 的章级知识菜单(spec §7 声明位):节拍/钩子/场景紧凑索引 + 场景候选。
- * 知识库缺失一律空集降级(旁路增益不是必经关卡)。
+ * 序6 的章级知识候选:只按当前语料查节拍/场景/技法/追读,每维最多三条。
+ * 知识库缺失或无机械命中一律空集降级,不回退全量菜单。
  */
-async function chapterKnowledge(ctx, { 当前卷, nextChapter }) {
+async function chapterKnowledge(ctx, { 当前卷, nextChapter, 本章任务 }) {
   if (!ctx.packageRoot) return {}
-  const fmt = (list) =>
-    list.map((e) => `${e.编号 ? `${e.编号} ` : ''}${e.名称}${e.一句话 ? `——${e.一句话}` : ''}`)
-  const 节拍 = await listChapterIndex(ctx.packageRoot, '节拍')
-  const 钩子 = await listChapterIndex(ctx.packageRoot, '追读')
-  const 场景 = await listChapterIndex(ctx.packageRoot, '场景')
-  // 场景候选语料:当前卷卷纲 + 上一章结尾——脚本只出候选,不拦截
+
+  let staged = { chapters: [] }
+  try {
+    staged = await stagedFacts(ctx.repoPath, { before: nextChapter, heal: false })
+  } catch {
+    // 批次不可读时按定稿历史继续,候选不阻断创作。
+  }
+  const recent = await readRecentChapterKnowledge(ctx.repoPath, {
+    before: nextChapter,
+    stagedChapters: staged.chapters,
+  })
+
   const corpus = []
+  if (typeof 本章任务 === 'string' && 本章任务.trim()) corpus.push(本章任务.trim())
   try {
     const vol = await new OutlineReader(ctx.repoPath).readVolumeOutline(当前卷)
-    if (vol.ok) corpus.push(vol.content || vol.text || '')
+    if (vol.ok) {
+      const content = vol.content || ''
+      corpus.push(extractChapterTask(content, nextChapter) || content)
+    }
   } catch {
     // 无卷纲
   }
-  if (Number.isInteger(nextChapter) && nextChapter > 1 && ctx.cache) {
-    try {
-      const t = await new ChapterReader(ctx.repoPath, ctx.cache).readTail(nextChapter - 1, 300)
-      if (t.ok) corpus.push(t.text)
-    } catch {
-      // 无上一章
+
+  if (Number.isInteger(nextChapter) && nextChapter > 1) {
+    const previousStaged = staged.chapters.find((chapter) => chapter.章号 === nextChapter - 1)
+    if (previousStaged) corpus.push([...String(previousStaged.body || '')].slice(-300).join(''))
+    else {
+      try {
+        const tail = await new ChapterReader(ctx.repoPath, ctx.cache).readTail(nextChapter - 1, 300)
+        if (tail.ok) corpus.push(tail.text)
+      } catch {
+        // 无上一章
+      }
     }
   }
-  const 候选 = await sceneCandidates(ctx.packageRoot, corpus)
-  const out = {}
-  if (节拍.length) out.节拍索引 = fmt(节拍)
-  if (钩子.length) out.钩子清单 = fmt(钩子)
-  if (场景.length) out.场景索引 = fmt(场景)
-  if (候选.length) out.场景候选 = 候选.map((c) => `${c.名称}${c.一句话 ? `——${c.一句话}` : ''}`)
-  return out
+
+  const problem = corpus.filter((text) => String(text || '').trim()).join('\n')
+  if (!problem) return {}
+  const results = await Promise.all(
+    CHAPTER_KNOWLEDGE_DIMENSIONS.map(async (dimension) => [
+      dimension,
+      await queryKnowledge(ctx.packageRoot, dimension, { 问题: problem, 近期: recent }),
+    ])
+  )
+  const candidates = {}
+  for (const [dimension, entries] of results) {
+    if (!entries.length) continue
+    candidates[dimension] = entries.map(toPlanningCandidate)
+  }
+  return Object.keys(candidates).length ? { 章级知识候选: candidates } : {}
+}
+
+function toPlanningCandidate(entry) {
+  return {
+    名称: entry.名称,
+    ...(entry.编号 ? { 编号: entry.编号 } : {}),
+    ...(entry.一句话 ? { 摘要: entry.一句话 } : {}),
+    ...(entry.规划 ? { 规划: entry.规划 } : {}),
+    来源: entry.来源版本,
+    ...(entry.近期使用 ? { 近期使用: entry.近期使用 } : {}),
+    ...(entry.重复提醒 ? { 重复提醒: entry.重复提醒 } : {}),
+  }
+}
+
+function extractChapterTask(volumeOutline, chapterNum) {
+  if (!Number.isInteger(chapterNum)) return ''
+  const pattern = new RegExp(`第\\s*0*${chapterNum}\\s*章`)
+  return String(volumeOutline || '')
+    .split(/\r?\n/)
+    .filter((line) => pattern.test(line))
+    .join('\n')
+    .trim()
 }
 
 // 序 3 的待定稿批次明细(无批次时不加字段)。heal:false——路由组包是名义只读路径,

+ 114 - 0
v7/test/knowledge/chapter.test.js

@@ -0,0 +1,114 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import os from 'node:os'
+import { fileURLToPath } from 'node:url'
+import { promises as fs } from 'node:fs'
+import {
+  archiveChapterKnowledgeFromOutline,
+  buildChapterKnowledgeArchive,
+  parseChapterDeclarations,
+  parseChapterKnowledgeArchive,
+  readRecentChapterKnowledge,
+  resolveChapterDeclarations,
+} from '../../src/knowledge/chapter.js'
+
+const packageRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..')
+
+const outline = [
+  '## 本章提案',
+  '本章节拍:PA-001 压抑蓄力爆发、微反转补刀',
+  '本章场景: 拍卖会、自定义梦境回廊',
+  '本章技法:限制视角误导',
+  '本章追读:悬念钩、选择钩',
+  '知识变体:拍卖会只写会后余波,不写逐口竞价',
+  '知识变体:悬念钩用于递进既有玉佩谜题',
+  '本章对象:CHAR-001、物品-001',
+  '章尾钩子:旧字段不得继续生效',
+].join('\n')
+
+test('章级声明一次性切换到四维、变体与对象,四维均可多选', () => {
+  const declarations = parseChapterDeclarations(outline)
+  assert.deepEqual(declarations.节拍, ['PA-001 压抑蓄力爆发', '微反转补刀'])
+  assert.deepEqual(declarations.场景, ['拍卖会', '自定义梦境回廊'])
+  assert.deepEqual(declarations.技法, ['限制视角误导'])
+  assert.deepEqual(declarations.追读, ['悬念钩', '选择钩'])
+  assert.deepEqual(declarations.变体, [
+    '拍卖会只写会后余波,不写逐口竞价',
+    '悬念钩用于递进既有玉佩谜题',
+  ])
+  assert.deepEqual(declarations.对象, ['CHAR-001', '物品-001'])
+  assert.equal(declarations.追读.includes('旧字段不得继续生效'), false)
+  assert.deepEqual(parseChapterDeclarations('## 本章提案'), {
+    节拍: [], 场景: [], 技法: [], 追读: [], 变体: [], 对象: [],
+  })
+})
+
+test('已选知识精确命中,未命中作为自定义原样归档;历史只解析最终选择与变体', async () => {
+  const resolved = await resolveChapterDeclarations(packageRoot, outline)
+  const pa1 = resolved.selections.find((item) => item.声明.startsWith('PA-001'))
+  assert.equal(pa1.条目.名称, '压抑蓄力爆发')
+  assert.ok(pa1.条目.落笔时.length > 0)
+  assert.equal(resolved.selections.find((item) => item.声明 === '限制视角误导').条目, null)
+
+  const archive = await buildChapterKnowledgeArchive(packageRoot, outline)
+  assert.ok(archive.includes('节拍|压抑蓄力爆发'))
+  assert.ok(archive.includes('场景|自定义梦境回廊'))
+  assert.ok(archive.includes('技法|限制视角误导'))
+  assert.ok(archive.includes('变体|拍卖会只写会后余波,不写逐口竞价'))
+  assert.ok(!archive.some((item) => item.includes('本章对象')))
+
+  const history = parseChapterKnowledgeArchive(archive, { chapterNum: 12 })
+  assert.ok(history.every((item) => item.章号 === 12))
+  assert.ok(history.every((item) => item.变体.includes('拍卖会只写会后余波')))
+  assert.ok(history.some((item) => item.维度 === '追读' && item.名称 === '悬念钩'))
+})
+
+test('近期历史合并定稿章与批内前章,按章倒序且不读取额外反馈表', async () => {
+  const repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-chapter-history-'))
+  try {
+    const dir = path.join(repoPath, '定稿', '正文')
+    await fs.mkdir(dir, { recursive: true })
+    await fs.writeFile(
+      path.join(dir, '0001-起步.md'),
+      '---\n章号: 1\n标题: 起步\n知识选择:\n  - 场景|拍卖会\n---\n正文。',
+      'utf8'
+    )
+    await fs.writeFile(
+      path.join(dir, '0002-推进.md'),
+      '---\n章号: 2\n标题: 推进\n知识选择:\n  - 追读|悬念钩\n  - 变体|递进旧谜题\n---\n正文。',
+      'utf8'
+    )
+    const history = await readRecentChapterKnowledge(repoPath, {
+      before: 4,
+      stagedChapters: [
+        { 章号: 3, frontMatter: { 知识选择: ['节拍|压抑蓄力爆发'] } },
+      ],
+    })
+    assert.deepEqual(history.map((item) => item.章号), [3, 2, 1])
+    assert.equal(history.find((item) => item.章号 === 2).变体, '递进旧谜题')
+  } finally {
+    await fs.rm(repoPath, { recursive: true, force: true })
+  }
+})
+
+test('归档以工作区确认细纲为准;细纲不存在时保留暂存 payload', async () => {
+  const repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-chapter-archive-'))
+  try {
+    await fs.mkdir(path.join(repoPath, '工作区'), { recursive: true })
+    await fs.writeFile(path.join(repoPath, '工作区', '细纲.md'), outline, 'utf8')
+    const payload = { frontMatter: { 标题: '测试', 知识选择: ['场景|旧值'] }, body: '正文。' }
+    const archived = await archiveChapterKnowledgeFromOutline({ repoPath, packageRoot }, payload)
+    assert.equal(archived.ok, true)
+    assert.equal(archived.found, true)
+    assert.ok(archived.payload.frontMatter.知识选择.includes('场景|拍卖会'))
+    assert.ok(!archived.payload.frontMatter.知识选择.includes('场景|旧值'))
+
+    await fs.rm(path.join(repoPath, '工作区', '细纲.md'))
+    const preserved = await archiveChapterKnowledgeFromOutline({ repoPath, packageRoot }, archived.payload)
+    assert.equal(preserved.found, false)
+    assert.deepEqual(preserved.payload.frontMatter.知识选择, archived.payload.frontMatter.知识选择)
+  } finally {
+    await fs.rm(repoPath, { recursive: true, force: true })
+  }
+})

+ 0 - 80
v7/test/knowledge/index.test.js

@@ -11,14 +11,9 @@ import {
   getDimensionDefinition,
   resolveLabel,
   resolveBookKnowledge,
-  resolveBookTags,
   readEntry,
   listKnowledgeEntries,
   queryKnowledge,
-  listChapterIndex,
-  findDeclared,
-  sceneCandidates,
-  parseOutlineDeclarations,
 } from '../../src/knowledge/index.js'
 
 // 真源知识库(v7 包根)——样例条目即测试夹具
@@ -48,40 +43,6 @@ test('resolveLabel 别名归一:修仙→仙侠、系统→系统流;未收
   assert.equal(resolveLabel(rows, ''), null)
 })
 
-test('resolveBookTags:命中带条目全文、条目待补降级为空串、未命中如实列出', async () => {
-  const r = await resolveBookTags(packageRoot, { 类型: '玄幻', 流派: ['退婚流', '自创流派'] })
-  assert.equal(r.题材命中.名称, '玄幻')
-  assert.match(r.题材命中.content, /骨架约定/)
-  assert.equal(r.流派命中[0].名称, '退婚流')
-  assert.deepEqual(r.未命中, ['自创流派'])
-  // 路由行存在但条目文件缺失(临时夹具):归一有效、内容降级为空
-  const tmp = await mkdtemp(path.join(os.tmpdir(), 'wnw-stub-ref-'))
-  try {
-    await mkdir(path.join(tmp, 'references'), { recursive: true })
-    await writeFile(
-      path.join(tmp, 'references', '路由.csv'),
-      '名称,维度,题材,条目,别名\n都市,题材,都市,题材/待补.md,\n',
-      'utf8'
-    )
-    const r2 = await resolveBookTags(tmp, { 类型: '都市', 流派: [] })
-    assert.equal(r2.题材命中.名称, '都市')
-    assert.equal(r2.题材命中.content, '')
-  } finally {
-    await rm(tmp, { recursive: true, force: true })
-  }
-})
-
-test('resolveBookTags 知识库缺失整体降级:全部未命中,不抛错', async () => {
-  const empty = await mkdtemp(path.join(os.tmpdir(), 'wnw-noref-'))
-  try {
-    const r = await resolveBookTags(empty, { 类型: '玄幻', 流派: ['系统流'] })
-    assert.equal(r.题材命中, null)
-    assert.deepEqual(r.未命中, ['玄幻', '系统流'])
-  } finally {
-    await rm(empty, { recursive: true, force: true })
-  }
-})
-
 test('resolveBookKnowledge:主题材/副题材/流派分别归一、去重,兼容只提醒不删除', async () => {
   const result = await resolveBookKnowledge(packageRoot, {
     类型: '修仙',
@@ -97,27 +58,6 @@ test('resolveBookKnowledge:主题材/副题材/流派分别归一、去重,
   assert.match(result.题材命中.来源版本, /^题材\/仙侠\.md@sha256:[0-9a-f]{64}$/)
 })
 
-test('listChapterIndex:节拍索引含编号/名称/一句话;缺目录返回空', async () => {
-  const beats = await listChapterIndex(packageRoot, '节拍')
-  const pa1 = beats.find((b) => b.编号 === 'PA-001')
-  assert.ok(pa1)
-  assert.equal(pa1.名称, '压抑蓄力爆发')
-  assert.ok(pa1.一句话.length > 0)
-  assert.deepEqual(await listChapterIndex(packageRoot, '不存在目录'), [])
-})
-
-test('findDeclared:编号命中、名称命中、混写命中、自定义返回 null', async () => {
-  const byId = await findDeclared(packageRoot, '节拍', 'PA-001')
-  assert.equal(byId.fm.名称, '压抑蓄力爆发')
-  assert.ok(byId.落笔时.length > 0)
-  const byName = await findDeclared(packageRoot, '追读', '悬念钩')
-  assert.equal(byName.fm.名称, '悬念钩')
-  const mixed = await findDeclared(packageRoot, '节拍', 'PA-002 微反转补刀')
-  assert.equal(mixed.fm.名称, '微反转补刀')
-  assert.equal(await findDeclared(packageRoot, '节拍', '自定义——先甜后抽'), null)
-  assert.equal(await findDeclared(packageRoot, '节拍', ''), null)
-})
-
 test('readEntry 三节切片;文件缺失返回 null', async () => {
   const e = await readEntry(packageRoot, '场景/拍卖会.md')
   assert.ok(e.规划.length > 0)
@@ -174,23 +114,3 @@ test('queryKnowledge:对象子类筛选有消费者,缺目录与非法维度
     await rm(tmp, { recursive: true, force: true })
   }
 })
-
-test('sceneCandidates 关键词命中出候选,只出候选不拦截;无语料返回空', async () => {
-  const hits = await sceneCandidates(packageRoot, ['本卷中段安排一场拍卖会,主角竞价夺宝'])
-  assert.ok(hits.some((h) => h.名称 === '拍卖会'))
-  assert.deepEqual(await sceneCandidates(packageRoot, []), [])
-  assert.deepEqual(await sceneCandidates(packageRoot, ['与场景无关的日常文本']), [])
-})
-
-test('parseOutlineDeclarations:全角/半角冒号、多值场景、缺省为空', () => {
-  const d = parseOutlineDeclarations(
-    ['## 本章提案', '本章定位:推进章。', '本章节拍:PA-001 压抑蓄力爆发', '章尾钩子: 悬念钩', '本章场景:拍卖会、突破'].join('\n')
-  )
-  assert.equal(d.节拍, 'PA-001 压抑蓄力爆发')
-  assert.equal(d.钩子, '悬念钩')
-  assert.deepEqual(d.场景, ['拍卖会', '突破'])
-  const empty = parseOutlineDeclarations('## 本章提案\n本章定位:过渡章。')
-  assert.equal(empty.节拍, '')
-  assert.equal(empty.钩子, '')
-  assert.deepEqual(empty.场景, [])
-})

+ 121 - 12
v7/test/knowledge/pipeline.test.js

@@ -13,6 +13,8 @@ import { collectRuntimeFiles } from '../../src/installer/vendor.js'
 import { run as knowledgePack } from '../../src/commands/knowledge-pack.js'
 import { tempBookCtx } from '../commands/_helper.js'
 import { minimalCreateBookPayload } from '../state-machine/_helper.js'
+import { stageChapter } from '../../src/staging/index.js'
+import { writeReviewArtifacts } from '../staging/_helper.js'
 
 // 真源知识库(v7 包根)——样例条目即夹具
 const packageRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..')
@@ -71,8 +73,10 @@ const 细纲带声明 = [
   '## 本章提案',
   '本章定位:推进章。',
   '本章节拍:PA-001 压抑蓄力爆发',
-  '章尾钩子:悬念钩',
   '本章场景:拍卖会、自定义梦境回廊',
+  '本章技法:限制视角误导',
+  '本章追读:悬念钩',
+  '知识变体:拍卖会只写会后余波,不写逐口竞价',
   '## 本章要写到的事(确认即生效)',
   '- [ ] 林晚查到玉佩的第一条线索',
   '## 备选',
@@ -92,14 +96,16 @@ test('备料注入:契约常驻 + 声明命中切片 + 自定义声明降级
     assert.match(c, /系统信息不可靠/)
     assert.match(c, /主角不得靠系统白给渡过主线危机/)
     assert.doesNotMatch(c, /恩怨清算档位/)
-    // 声明命中:节拍/钩子/场景的「落笔时」节
+    // 声明命中:四维选择只注入各自的「落笔时」节
     assert.match(c, /### 本章节拍:PA-001 压抑蓄力爆发/)
     assert.match(c, /爆发必须改写局面/)
-    assert.match(c, /### 章尾钩子:悬念钩/)
     assert.match(c, /### 本章场景:拍卖会/)
     assert.match(c, /竞价过程写节点不写流水/)
+    assert.match(c, /### 本章追读:悬念钩/)
     // 自定义声明降级:声明本身即知识
-    assert.match(c, /### 本章场景:自定义梦境回廊\n(知识库无此条目,按声明执行)/)
+    assert.match(c, /### 本章场景:自定义梦境回廊\n(自定义选择,按细纲声明执行)/)
+    assert.match(c, /### 本章技法:限制视角误导\n(自定义选择,按细纲声明执行)/)
+    assert.match(c, /### 知识变体\n- 拍卖会只写会后余波,不写逐口竞价/)
   } finally {
     await cleanup()
   }
@@ -143,6 +149,8 @@ test('审稿输入:冻结契约 + 声明命中的审稿切片,不回读当
     assert.match(r.input.作品契约, /主角不得靠系统白给/)
     assert.ok(r.input.知识审查.some((item) => item.startsWith('【压抑蓄力爆发·核对】')))
     assert.ok(r.input.知识审查.some((item) => item.startsWith('【拍卖会·核对】')))
+    assert.ok(r.input.知识审查.some((item) => item.includes('【技法·自定义】')))
+    assert.ok(r.input.知识审查.some((item) => item.includes('【知识变体】')))
     assert.equal(r.input.毒点清单, undefined)
   } finally {
     await cleanup()
@@ -176,20 +184,121 @@ test('序1 DTO:知识路由菜单 + 蒸馏期望产物(A1)', async () => {
   assert.match(dto.期望产物, /作者已确认:true/)
 })
 
-test('序6 DTO:节拍/钩子/场景索引 + 场景候选(卷纲命中)', async () => {
+test('序6 DTO:四维只给按本章语料命中的少量候选,不暴露全量菜单', async () => {
   const { ctx, cleanup } = await tempBookCtx()
   try {
     ctx.packageRoot = packageRoot
-    // 卷纲里埋场景关键词 → 候选命中
-    await fs.appendFile(path.join(ctx.repoPath, '大纲', '卷纲', '第01卷.md'), '\n中段安排一场拍卖会夺宝。\n', 'utf8')
+    await fs.appendFile(
+      path.join(ctx.repoPath, '大纲', '卷纲', '第01卷.md'),
+      '\n- 第3章安排拍卖会竞价,先抑后扬,章尾使用悬念钩。\n',
+      'utf8'
+    )
     const dto = await buildDto(ctx, 6, { nextChapter: 3 })
-    assert.ok(dto.节拍索引.some((s) => s.includes('PA-001')))
-    assert.ok(dto.钩子清单.some((s) => s.includes('悬念钩')))
-    assert.ok(dto.场景索引.some((s) => s.includes('拍卖会')))
-    assert.ok(dto.场景候选.some((s) => s.includes('拍卖会')))
+    assert.ok(dto.章级知识候选.节拍.some((item) => item.编号 === 'PA-001'))
+    assert.ok(dto.章级知识候选.场景.some((item) => item.名称 === '拍卖会'))
+    assert.ok(dto.章级知识候选.追读.some((item) => item.名称 === '悬念钩'))
+    for (const entries of Object.values(dto.章级知识候选)) {
+      assert.ok(entries.length <= 3)
+      assert.ok(entries.every((item) => item.来源.includes('@sha256:')))
+      assert.ok(entries.every((item) => !('文件' in item)))
+    }
+    assert.equal(dto.节拍索引, undefined)
+    assert.equal(dto.钩子清单, undefined)
+    assert.equal(dto.场景索引, undefined)
     assert.match(dto.期望产物, /本章节拍/)
+    assert.match(dto.期望产物, /本章技法/)
+    assert.match(dto.期望产物, /完全自定义/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序6 DTO:批内前章选择只触发软重复信号,不硬排除合法复用', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    ctx.packageRoot = packageRoot
+    const stagedOutline = [
+      '# 第 3 章细纲',
+      '## 本章提案',
+      '本章场景:拍卖会',
+      '知识变体:第 3 章只写竞价,第 4 章承接会后追踪',
+      '## 本章要写到的事(确认即生效)',
+      '- [ ] 完成拍卖竞价',
+    ].join('\n')
+    await fs.writeFile(path.join(ctx.repoPath, '工作区', '细纲.md'), stagedOutline, 'utf8')
+    await writeReviewArtifacts(ctx.repoPath, 3)
+    const staged = await stageChapter(ctx, {
+      chapterNum: 3,
+      payload: {
+        frontMatter: {
+          章号: 3,
+          标题: '拍卖落槌',
+          卷: 1,
+          书内时间: '春月初三',
+          字数: 100,
+          章定位: '推进',
+          钩子: '悬念钩-中',
+          情绪定位: '抬升',
+        },
+        body: '竞价落槌后,林晚发现有人跟踪。',
+      },
+    })
+    assert.equal(staged.ok, true, staged.error)
+    await fs.appendFile(
+      path.join(ctx.repoPath, '大纲', '卷纲', '第01卷.md'),
+      '\n- 第4章继续拍卖会余波,追查竞价者。\n',
+      'utf8'
+    )
+
+    const dto = await buildDto(ctx, 6, { nextChapter: 4 })
+    const auction = dto.章级知识候选.场景.find((item) => item.名称 === '拍卖会')
+    assert.ok(auction, '近期用过的合适候选仍须保留')
+    assert.deepEqual(auction.近期使用, [
+      { 章号: 3, 变体: '第 3 章只写竞价,第 4 章承接会后追踪' },
+    ])
+    assert.match(auction.重复提醒, /软降权/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序6 DTO:技法维度使用同一候选接口,并把规划切片交给真实消费者', async () => {
+  const packageFixture = await mkdtemp(path.join(os.tmpdir(), 'wnw-technique-ref-'))
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    const dir = path.join(packageFixture, 'references', '技法')
+    await mkdir(dir, { recursive: true })
+    await writeFile(
+      path.join(dir, '限制视角误导.md'),
+      [
+        '---',
+        '名称: 限制视角误导',
+        '类别: 视角',
+        '作用层级: 场景',
+        '触发问题:',
+        '  - 信息误导',
+        '一句话: 只给视角人物能确认的信息',
+        '---',
+        '## 规划这一章时',
+        '先列出视角人物知道与不知道的事实。',
+        '## 落笔时',
+        '不得越过视角人物认知补叙真相。',
+        '## 审稿时',
+        '核对是否出现越权信息。',
+      ].join('\n'),
+      'utf8'
+    )
+    ctx.packageRoot = packageFixture
+    const dto = await buildDto(ctx, 6, {
+      nextChapter: 3,
+      本章任务: '本章用限制视角误导隐藏真相',
+    })
+    assert.equal(dto.章级知识候选.技法[0].名称, '限制视角误导')
+    assert.match(dto.章级知识候选.技法[0].规划, /知道与不知道/)
+    assert.match(dto.章级知识候选.技法[0].来源, /^技法\/限制视角误导\.md@sha256:/)
   } finally {
     await cleanup()
+    await rm(packageFixture, { recursive: true, force: true })
   }
 })
 
@@ -245,7 +354,7 @@ test('知识库整体缺失时序1/序6 DTO 零报错(A3)', async () => {
       ctx.packageRoot = empty
       const dto6 = await buildDto(ctx, 6, { nextChapter: 3 })
       assert.equal(dto6.state, 'draft-outline')
-      assert.ok(!dto6.节拍索引)
+      assert.ok(!dto6.章级知识候选)
     } finally {
       await cleanup()
     }

+ 33 - 0
v7/test/staging/finalize-batch.test.js

@@ -48,12 +48,26 @@ function chapterPayload(num, { body, 钩子 = '危机钩-强' } = {}) {
 }
 
 async function stage(ctx, num, opts) {
+  await fs.writeFile(path.join(ctx.repoPath, '工作区', '细纲.md'), chapterOutline(num), 'utf8')
   await writeReviewArtifacts(ctx.repoPath, num)
   const r = await stageChapter(ctx, { chapterNum: num, payload: chapterPayload(num, opts) })
   assert.equal(r.ok, true, r.error)
   return r
 }
 
+function chapterOutline(num) {
+  return [
+    `# 第 ${num} 章细纲`,
+    '## 本章提案',
+    '本章节拍:压抑蓄力爆发',
+    '本章场景:拍卖会',
+    '本章追读:悬念钩',
+    `知识变体:第 ${num} 章按递进关系落地`,
+    '## 本章要写到的事(确认即生效)',
+    `- [ ] 完成第 ${num} 章任务`,
+  ].join('\n')
+}
+
 // 定稿/大纲 全树快照(相对路径 → 内容),批次转正 vs 手动定稿逐字段对比
 async function snapshotTree(repoPath) {
   const map = {}
@@ -84,6 +98,11 @@ test('AC1 批次端到端:stage×3 → finalize-batch 逐章 commit,入档
   const manualRepo = await gitBookCtx()
   try {
     for (const n of [3, 4, 5]) await stage(batchRepo.ctx, n)
+    await fs.writeFile(
+      path.join(batchRepo.ctx.repoPath, '工作区', '细纲.md'),
+      '# 另一章未完成细纲\n本章场景:不得覆盖批内选择',
+      'utf8'
+    )
 
     // AC7 后半:批内期间 fingerprints/meta 零 staged 污染
     assert.equal(
@@ -107,12 +126,19 @@ test('AC1 批次端到端:stage×3 → finalize-batch 逐章 commit,入档
     assert.equal((await readBatch(batchRepo.ctx.repoPath)).exists, false, '批次目录清空')
     assert.match(r.体检, /体检/)
 
+    await fs.rm(path.join(batchRepo.ctx.repoPath, '工作区', '细纲.md'))
+
     const nx = await determineNextState(batchRepo.ctx)
     assert.equal(nx.序, 6, JSON.stringify(nx))
     assert.match(nx.message, /第 6 章/)
 
     // 手动模式同 payload 逐章定稿 → 定稿/大纲 全树逐字段一致
     for (const n of [3, 4, 5]) {
+      await fs.writeFile(
+        path.join(manualRepo.ctx.repoPath, '工作区', '细纲.md'),
+        chapterOutline(n),
+        'utf8'
+      )
       const m = await finalizeChapter(manualRepo.ctx, chapterPayload(n))
       assert.equal(m.ok, true, m.error)
     }
@@ -120,6 +146,13 @@ test('AC1 批次端到端:stage×3 → finalize-batch 逐章 commit,入档
       await snapshotTree(batchRepo.ctx.repoPath),
       await snapshotTree(manualRepo.ctx.repoPath)
     )
+    const chapter3 = await fs.readFile(
+      path.join(batchRepo.ctx.repoPath, '定稿', '正文', '0003-连写3.md'),
+      'utf8'
+    )
+    assert.match(chapter3, /知识选择:\n  - 节拍|压抑蓄力爆发/)
+    assert.match(chapter3, /变体|第 3 章按递进关系落地/)
+    assert.doesNotMatch(chapter3, /不得覆盖批内选择/)
   } finally {
     await batchRepo.cleanup()
     await manualRepo.cleanup()