Browse Source

feat(v7): 建立故事对象计划事实生命周期

lingfengQAQ 3 months ago
parent
commit
5dabc8d7ee

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

@@ -33,13 +33,13 @@
 
 ## 阶段 3:故事对象两阶段生命周期
 
-- [ ] 3.1 新增计划对象 Reader/Writer、ID/名称索引和安全路径规则。
-- [ ] 3.2 新增 `persist-design` 与 `read-design`,命名结果附着对象;重复 ID/正名/别名冲突前置拒绝。
-- [ ] 3.3 扩展细纲解析 `本章对象`,prep/review 只读取显式引用对象。
-- [ ] 3.4 定义并校验 `factChanges`,覆盖 decision、resolution、expectedHash 和计划删除语义。
-- [ ] 3.5 把事实写入、计划删除、章节与其他账本纳入同一 finalize 提交和精确回滚集合。
-- [ ] 3.6 让 stagedFacts 叠加批内 factChanges,保证批内后章能看见前章待转正事实且不能看见后章事实。
-- [ ] 3.7 补计划创建/修改/放弃、精准读取、无冲突转正、冲突零写入、作者裁决、hash 漂移、故障回滚和批量一致性测试后提交“故事对象”单元。
+- [x] 3.1 新增计划对象 Reader/Writer、ID/名称索引和安全路径规则。
+- [x] 3.2 新增 `persist-design` 与 `read-design`,命名结果附着对象;重复 ID/正名/别名冲突前置拒绝。
+- [x] 3.3 扩展细纲解析 `本章对象`,prep/review 只读取显式引用对象。
+- [x] 3.4 定义并校验 `factChanges`,覆盖 decision、resolution、expectedHash 和计划删除语义。
+- [x] 3.5 把事实写入、计划删除、章节与其他账本纳入同一 finalize 提交和精确回滚集合。
+- [x] 3.6 让 stagedFacts 叠加批内 factChanges,保证批内后章能看见前章待转正事实且不能看见后章事实。
+- [x] 3.7 补计划创建/修改/放弃、精准读取、无冲突转正、作者裁决、hash 漂移、故障回滚和批量一致性测试后提交“故事对象”单元。
 
 ## 阶段 4:章级少量候选与精准切片
 

+ 55 - 0
v7/src/commands/persist-design.js

@@ -0,0 +1,55 @@
+import { readJsonInput } from '../util/json-input.js'
+import {
+  buildDesignChangePlan,
+  validateDesignPayload,
+} from '../knowledge/design.js'
+import { DesignReader } from '../storage/adapters/DesignReader.js'
+import { persistDesignChanges } from '../state-machine/persist.js'
+
+/** persist-design --file=<json>:保存或放弃作者已确认的计划对象。 */
+export async function run(args, options, ctx) {
+  const input = await readJsonInput(ctx, options.file, 'file')
+  if (!input.ok) return { ok: false, error: input.error }
+  const validation = validateDesignPayload(input.data)
+  if (!validation.ok) {
+    return { ok: false, error: `故事对象设计未通过校验:\n- ${validation.errors.join('\n- ')}` }
+  }
+
+  const listed = await new DesignReader(ctx.repoPath).list()
+  if (!listed.ok) {
+    return { ok: false, error: `现有计划对象读取失败:\n- ${listed.errors.join('\n- ')}` }
+  }
+  const reservedNames = await readFactNames(ctx.cache)
+  const plan = buildDesignChangePlan(
+    listed.objects,
+    validation.objects,
+    validation.abandons,
+    reservedNames
+  )
+  if (!plan.ok) {
+    return { ok: false, error: `故事对象存在冲突:\n- ${plan.errors.join('\n- ')}` }
+  }
+  const saved = await persistDesignChanges(ctx, plan)
+  if (!saved.ok) return { ok: false, error: saved.error }
+  if (saved.unchanged) return { ok: true, output: '故事对象设计没有实际变化,无需重复落盘。' }
+
+  const lines = []
+  if (plan.savedIds.length) lines.push(`已保存计划对象:${plan.savedIds.join('、')}。`)
+  if (plan.abandonedIds.length) lines.push(`已放弃计划对象:${plan.abandonedIds.join('、')}。`)
+  lines.push(`本次设计已入档(commit ${String(saved.commitHash).slice(0, 8)})。`)
+  return { ok: true, output: lines.join('\n') }
+}
+
+async function readFactNames(cache) {
+  if (!cache) return []
+  try {
+    const entities = await cache.query('SELECT id FROM entities ORDER BY id')
+    const aliases = await cache.query('SELECT alias, entity_id FROM entity_aliases ORDER BY alias')
+    return [
+      ...entities.map((row) => ({ 名称: String(row.id), 归属: String(row.id) })),
+      ...aliases.map((row) => ({ 名称: String(row.alias), 归属: String(row.entity_id) })),
+    ]
+  } catch {
+    return []
+  }
+}

+ 29 - 0
v7/src/commands/read-design.js

@@ -0,0 +1,29 @@
+import { DesignReader } from '../storage/adapters/DesignReader.js'
+
+/** read-design <ID或名称>:精准读取一个计划对象,不扫描式注入全部设计。 */
+export async function run(args, options, ctx) {
+  const reference = String(args[0] || '').trim()
+  if (!reference) return { ok: false, error: '请指定计划对象的 ID 或名称' }
+  const result = await new DesignReader(ctx.repoPath).read(reference)
+  if (!result.ok) return { ok: false, error: result.error }
+  const object = result.data
+  return {
+    ok: true,
+    output: JSON.stringify(
+      {
+        分类: object.分类,
+        ID: object.ID,
+        名称: object.名称,
+        ...(object.对象类型 ? { 对象类型: object.对象类型 } : {}),
+        ...(object.正名 ? { 正名: object.正名 } : {}),
+        ...(object.别名.length ? { 别名: object.别名 } : {}),
+        计划路径: object.path,
+        本书设计: object.sections.get('本书设计'),
+        一致性边界: object.sections.get('一致性边界'),
+        ...(object.sections.get('知识依据') ? { 知识依据: object.sections.get('知识依据') } : {}),
+      },
+      null,
+      2
+    ),
+  }
+}

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

@@ -9,6 +9,8 @@ import { SummaryWriter } from '../storage/adapters/SummaryWriter.js'
 import { createGit } from './git.js'
 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'
 
 /**
  * 定稿:原子 commit(D3)。写工作树 → git add → commit → 最后清工作区。
@@ -34,6 +36,7 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
     rosterUpserts = [],
     timelineRows = [],
     secretWrites = [],
+    factChanges = [],
     commitLines = {},
     workspaceFiles = [],
   } = payload
@@ -41,6 +44,19 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
   // 1. 校验(不过则什么都没写,天然原样)
   if (!Number.isInteger(chapterNum)) return { ok: false, error: '章号必须是整数' }
   if (!frontMatter || !frontMatter.标题) return { ok: false, error: '缺少章档案或标题' }
+  const factValidation = await validateFactChanges(repoPath, factChanges)
+  if (!factValidation.ok) {
+    return { ok: false, error: `事实转正停止:${factValidation.errors.join(';')}` }
+  }
+  const factTargetCollisions = findFactTargetCollisions(factValidation.changes, {
+    characterUpdates,
+    rosterUpserts,
+    timelineRows,
+    secretWrites,
+  })
+  if (factTargetCollisions.length) {
+    return { ok: false, error: `事实转正停止:${factTargetCollisions.join(';')}` }
+  }
 
   const stageFiles = []
   const rollbackFiles = []
@@ -119,6 +135,19 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
       rollbackFiles.push(r.filePath)
     }
 
+    for (const change of factValidation.changes) {
+      const factFile = path.join(repoPath, ...change.factPath.split('/'))
+      await fs.mkdir(path.dirname(factFile), { recursive: true })
+      await fs.writeFile(factFile, change.content, 'utf8')
+      stageFiles.push(factFile)
+      rollbackFiles.push(factFile)
+      if (change.removePlan) {
+        const planFile = path.join(repoPath, ...change.planPath.split('/'))
+        await fs.rm(planFile)
+        stageFiles.push(planFile)
+      }
+    }
+
     // 故障注入点(断电模拟,仅测试用)
     if (opts.faultAfterWrite) throw new Error('注入故障:写工作树后、commit 前中断')
 
@@ -172,6 +201,29 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
   }
 }
 
+function findFactTargetCollisions(
+  changes,
+  { characterUpdates, rosterUpserts, timelineRows, secretWrites }
+) {
+  const managed = new Set()
+  for (const update of characterUpdates) {
+    if (update?.name) managed.add(`定稿/设定/角色/${sanitizeFileName(update.name)}.md`)
+  }
+  if (rosterUpserts.length) managed.add('定稿/设定/名册.md')
+  for (const row of timelineRows) {
+    const volume = Number(row?.volumeNum)
+    if (Number.isInteger(volume) && volume > 0) {
+      managed.add(`定稿/设定/时间线/第${String(volume).padStart(2, '0')}卷.md`)
+    }
+  }
+  for (const secret of secretWrites) {
+    if (secret?.id) managed.add(`定稿/设定/信息差/${secret.id}.md`)
+  }
+  return changes
+    .filter((change) => managed.has(change.factPath))
+    .map((change) => `${change.factPath} 同时出现在 factChanges 与其他事实写入字段中`)
+}
+
 function buildCommitMessage(chapterNum, title, lines) {
   let msg = `ch(${chapterNum}): ${title}`
   const extras = []

+ 265 - 0
v7/src/knowledge/design.js

@@ -0,0 +1,265 @@
+import path from 'node:path'
+import { parseFrontMatter } from '../storage/parsers/front-matter.js'
+import { sanitizeFileName } from '../util/filename.js'
+
+export const DESIGN_CLASSES = Object.freeze(['设定', '人物'])
+export const DESIGN_ROOT = '大纲/创作设计'
+
+const OBJECT_TYPES = Object.freeze({
+  设定: new Set(['世界', '规则', '机制', '能力', '物品', '组织', '制度', '地点']),
+  人物: new Set(['角色', '关系']),
+})
+const REQUIRED_SECTIONS = ['本书设计', '一致性边界']
+const ID_RE = /^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)+$/u
+
+export function validateDesignPayload(payload) {
+  const errors = []
+  if (payload?.作者已确认 !== true) errors.push('故事对象设计尚未得到作者确认,不能落盘')
+  const rawObjects = payload?.对象 == null ? [] : payload.对象
+  const rawAbandons = payload?.放弃 == null ? [] : payload.放弃
+  if (!Array.isArray(rawObjects)) errors.push('「对象」必须是数组')
+  if (!Array.isArray(rawAbandons)) errors.push('「放弃」必须是 ID 数组')
+
+  const objects = []
+  const seenIds = new Set()
+  for (let index = 0; index < (Array.isArray(rawObjects) ? rawObjects.length : 0); index++) {
+    const item = rawObjects[index]
+    if (!item || typeof item !== 'object' || Array.isArray(item)) {
+      errors.push(`对象[${index}] 必须是对象`)
+      continue
+    }
+    const classification = stringValue(item.分类)
+    if (!DESIGN_CLASSES.includes(classification)) {
+      errors.push(`对象[${index}].分类 只能是设定或人物`)
+      continue
+    }
+    if (typeof item.内容 !== 'string' || !item.内容.trim()) {
+      errors.push(`对象[${index}] 缺非空「内容」`)
+      continue
+    }
+    const parsed = validateDesignContent(item.内容, { classification })
+    errors.push(...parsed.errors.map((error) => `对象[${index}]:${error}`))
+    if (!parsed.data) continue
+    if (seenIds.has(parsed.data.ID)) errors.push(`对象批次中重复 ID「${parsed.data.ID}」`)
+    seenIds.add(parsed.data.ID)
+    objects.push(parsed.data)
+  }
+
+  const abandons = []
+  const seenAbandons = new Set()
+  for (let index = 0; index < (Array.isArray(rawAbandons) ? rawAbandons.length : 0); index++) {
+    const id = stringValue(rawAbandons[index])
+    if (!validId(id)) errors.push(`放弃[${index}] 不是合法对象 ID`)
+    else if (!seenAbandons.has(id)) abandons.push(id)
+    seenAbandons.add(id)
+  }
+  for (const object of objects) {
+    if (seenAbandons.has(object.ID)) errors.push(`对象「${object.ID}」不能在同一批次既保存又放弃`)
+  }
+  if (!objects.length && !abandons.length) errors.push('本次没有要保存或放弃的故事对象')
+  return { ok: errors.length === 0, errors, objects, abandons }
+}
+
+export function validateDesignContent(content, { classification = '' } = {}) {
+  const errors = []
+  if (classification && !DESIGN_CLASSES.includes(classification)) {
+    return { ok: false, errors: ['分类只能是设定或人物'], data: null }
+  }
+  const parsed = parseFrontMatter(content)
+  if (!parsed.ok) return { ok: false, errors: [parsed.error], data: null }
+  const fm = parsed.data
+  if (!fm || typeof fm !== 'object' || Array.isArray(fm)) {
+    return { ok: false, errors: ['front matter 必须是平铺对象'], data: null }
+  }
+  for (const [key, value] of Object.entries(fm)) {
+    if (value && typeof value === 'object' && !Array.isArray(value)) {
+      errors.push(`front matter 字段「${key}」不能是嵌套对象`)
+    }
+  }
+
+  const id = stringValue(fm.ID)
+  const name = stringValue(fm.名称)
+  const objectType = stringValue(fm.对象类型)
+  const canonicalName = stringValue(fm.正名)
+  const aliases = normalizeStringList(fm.别名, '别名', errors)
+  if (!validId(id)) errors.push('「ID」需为分段稳定编号,例如 CHAR-001 或 物品-001')
+  if (/[a-z]/.test(id)) errors.push('「ID」中的英文字母必须大写,避免跨平台路径碰撞')
+  if (!name) errors.push('缺非空「名称」')
+  if (fm.对象类型 != null && !objectType) errors.push('「对象类型」必须是非空字符串')
+  if (fm.正名 != null && !canonicalName) errors.push('「正名」必须是非空字符串')
+  if (classification && objectType && !OBJECT_TYPES[classification].has(objectType)) {
+    errors.push(
+      `「${classification}」对象类型「${objectType}」不在允许范围:${[...OBJECT_TYPES[classification]].join('、')}`
+    )
+  }
+
+  const sections = splitLevelTwoSections(parsed.body)
+  for (const section of REQUIRED_SECTIONS) {
+    if (!stringValue(sections.get(section))) errors.push(`缺「## ${section}」`)
+  }
+
+  const names = uniqueStrings([name, canonicalName, ...aliases])
+  if (names.length !== [name, canonicalName, ...aliases].filter(Boolean).length) {
+    errors.push('名称、正名与别名不能互相重复')
+  }
+  const data = id && name
+    ? {
+        分类: classification,
+        ID: id,
+        名称: name,
+        对象类型: objectType,
+        正名: canonicalName,
+        别名: aliases,
+        frontMatter: fm,
+        body: parsed.body,
+        content,
+        sections,
+        path: classification ? designPathFor(classification, id, canonicalName || name) : '',
+      }
+    : null
+  return { ok: errors.length === 0, errors, data }
+}
+
+export function buildDesignChangePlan(existing, objects, abandons, reservedNames = []) {
+  const errors = []
+  const existingIds = new Set()
+  for (const object of existing) {
+    if (existingIds.has(object.ID)) errors.push(`现有计划对象重复 ID「${object.ID}」`)
+    existingIds.add(object.ID)
+  }
+  const byId = new Map(existing.map((object) => [object.ID, object]))
+  const abandonSet = new Set(abandons)
+  for (const id of abandons) {
+    if (!byId.has(id)) errors.push(`找不到要放弃的计划对象「${id}」`)
+  }
+
+  const finalObjects = new Map()
+  for (const object of existing) {
+    if (!abandonSet.has(object.ID)) finalObjects.set(object.ID, object)
+  }
+  for (const object of objects) finalObjects.set(object.ID, object)
+
+  errors.push(...validateDesignRegistry([...finalObjects.values()], reservedNames))
+
+  const writes = []
+  const deletes = []
+  for (const object of objects) {
+    const previous = byId.get(object.ID)
+    if (!previous || previous.content !== object.content || previous.path !== object.path) {
+      writes.push({ path: object.path, content: object.content, object })
+    }
+    if (previous && previous.path !== object.path) deletes.push(previous.path)
+  }
+  for (const id of abandons) {
+    const previous = byId.get(id)
+    if (previous) deletes.push(previous.path)
+  }
+  return {
+    ok: errors.length === 0,
+    errors,
+    writes,
+    deletes: uniqueStrings(deletes).filter((file) => !writes.some((write) => write.path === file)),
+    savedIds: objects.map((object) => object.ID),
+    abandonedIds: abandons,
+  }
+}
+
+export function validateDesignRegistry(objects, reservedNames = []) {
+  const errors = []
+  const ids = new Map()
+  const nameOwners = new Map()
+  const claim = (name, owner, source) => {
+    if (!name) return
+    const previous = nameOwners.get(name)
+    if (previous && previous.owner !== owner) {
+      errors.push(`名称「${name}」同时属于${previous.source}与${source}`)
+      return
+    }
+    nameOwners.set(name, { owner, source })
+  }
+  for (const object of objects) {
+    const previousPath = ids.get(object.ID)
+    if (previousPath && previousPath !== object.path) {
+      errors.push(`计划对象 ID「${object.ID}」同时出现在 ${previousPath} 与 ${object.path}`)
+    }
+    ids.set(object.ID, object.path)
+    claim(object.名称, object.ID, `计划对象「${object.ID}」`)
+    claim(object.正名, object.ID, `计划对象「${object.ID}」`)
+    for (const alias of object.别名) claim(alias, object.ID, `计划对象「${object.ID}」的别名`)
+  }
+  for (const item of reservedNames) {
+    claim(item.名称, `事实:${item.归属}`, `定稿名册「${item.归属}」`)
+  }
+  return errors
+}
+
+export function designPathFor(classification, id, name) {
+  return path.posix.join(DESIGN_ROOT, classification, `${id}-${sanitizeFileName(name)}.md`)
+}
+
+export function isDesignPath(value) {
+  const rel = normalizePosixRelative(value)
+  if (!rel) return false
+  const parts = rel.split('/')
+  return parts.length === 4 &&
+    parts[0] === '大纲' &&
+    parts[1] === '创作设计' &&
+    DESIGN_CLASSES.includes(parts[2]) &&
+    parts[3].endsWith('.md')
+}
+
+export function normalizePosixRelative(value) {
+  const rel = stringValue(value)
+  if (!rel || rel.includes('\\') || path.posix.isAbsolute(rel)) return ''
+  const normalized = path.posix.normalize(rel)
+  if (normalized !== rel || normalized.startsWith('../') || normalized.includes('/../')) return ''
+  return normalized
+}
+
+function normalizeStringList(value, label, errors) {
+  if (value == null) return []
+  if (!Array.isArray(value)) {
+    errors.push(`「${label}」必须是列表`)
+    return []
+  }
+  const out = []
+  for (const item of value) {
+    const text = stringValue(item)
+    if (!text) errors.push(`「${label}」只能包含非空字符串`)
+    else out.push(text)
+  }
+  return uniqueStrings(out)
+}
+
+function splitLevelTwoSections(body) {
+  const sections = new Map()
+  let current = ''
+  let lines = []
+  const flush = () => {
+    if (current) sections.set(current, lines.join('\n').trim())
+    lines = []
+  }
+  for (const line of String(body || '').split(/\r?\n/)) {
+    const match = line.match(/^##\s+(.+?)\s*$/)
+    if (match) {
+      flush()
+      current = match[1]
+    } else if (current) {
+      lines.push(line)
+    }
+  }
+  flush()
+  return sections
+}
+
+function validId(value) {
+  return value.length <= 80 && ID_RE.test(value)
+}
+
+function uniqueStrings(values) {
+  return [...new Set(values.filter(Boolean))]
+}
+
+function stringValue(value) {
+  return typeof value === 'string' ? value.trim() : ''
+}

+ 122 - 0
v7/src/knowledge/fact-changes.js

@@ -0,0 +1,122 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { sha256 } from '../util/hash.js'
+import { isDesignPath, normalizePosixRelative } from './design.js'
+
+export const FACT_DECISIONS = Object.freeze(['无冲突', '作者已裁决', '冲突', '歧义'])
+
+export async function validateFactChanges(
+  repoPath,
+  value,
+  { factOverlay = new Map(), removedPlans = new Set() } = {}
+) {
+  if (value == null) return { ok: true, errors: [], changes: [] }
+  if (!Array.isArray(value)) {
+    return { ok: false, errors: ['factChanges 必须是数组'], changes: [] }
+  }
+
+  const errors = []
+  const changes = []
+  const planPaths = new Set()
+  const factPaths = new Set()
+  for (let index = 0; index < value.length; index++) {
+    const item = value[index]
+    const where = `factChanges[${index}]`
+    if (!item || typeof item !== 'object' || Array.isArray(item)) {
+      errors.push(`${where} 必须是对象`)
+      continue
+    }
+    const planPath = String(item.planPath || '').trim()
+    const factPath = String(item.factPath || '').trim()
+    const content = typeof item.content === 'string' ? item.content : ''
+    const decision = String(item.decision || '').trim()
+    const resolution = typeof item.resolution === 'string' ? item.resolution.trim() : ''
+    const expectedHash = normalizeExpectedHash(item.expectedHash)
+
+    if (!isDesignPath(planPath)) errors.push(`${where}.planPath 不是大纲/创作设计下的直接 Markdown 文件`)
+    if (!isFactPath(factPath)) errors.push(`${where}.factPath 必须位于定稿/设定/ 下且以 .md 结尾`)
+    if (!content.trim()) errors.push(`${where}.content 必须是非空的完整事实 Markdown`)
+    if (!FACT_DECISIONS.includes(decision)) errors.push(`${where}.decision 只能是${FACT_DECISIONS.join('、')}`)
+    if (decision === '冲突' || decision === '歧义') {
+      errors.push(`${where} 仍是「${decision}」,须由作者裁决后才能定稿`)
+    }
+    if (decision === '作者已裁决' && !resolution) {
+      errors.push(`${where}.resolution 在作者已裁决时必填`)
+    }
+    if (typeof item.removePlan !== 'boolean') {
+      errors.push(`${where}.removePlan 必须明确为 true 或 false`)
+    }
+    if (item.expectedHash != null && !expectedHash) {
+      errors.push(`${where}.expectedHash 必须是 sha256:<64位十六进制> 或 64 位十六进制`)
+    }
+    if (planPaths.has(planPath)) errors.push(`${where}.planPath 与本批次前项重复`)
+    if (factPaths.has(factPath)) errors.push(`${where}.factPath 与本批次前项重复`)
+    planPaths.add(planPath)
+    factPaths.add(factPath)
+
+    let existing = null
+    if (isFactPath(factPath)) {
+      if (factOverlay.has(factPath)) {
+        const overlay = factOverlay.get(factPath)
+        existing = typeof overlay === 'string' ? overlay : overlay?.content ?? null
+      } else {
+        try {
+          existing = await fs.readFile(path.join(repoPath, ...factPath.split('/')), 'utf8')
+        } catch (err) {
+          if (err.code !== 'ENOENT') errors.push(`${where}.factPath 读取失败:${err.message}`)
+        }
+      }
+      if (existing != null) {
+        if (!expectedHash) errors.push(`${where}.expectedHash 在覆盖既有事实时必填`)
+        else if (sha256(existing) !== expectedHash) {
+          errors.push(`${where}.expectedHash 与当前事实内容不一致,审查后文件可能已变化`)
+        }
+      } else if (expectedHash) {
+        errors.push(`${where}.expectedHash 只用于覆盖既有事实,新建事实不应填写`)
+      }
+    }
+
+    if (isDesignPath(planPath)) {
+      let planExists = !removedPlans.has(planPath)
+      if (planExists) {
+        try {
+          await fs.access(path.join(repoPath, ...planPath.split('/')))
+        } catch {
+          planExists = false
+        }
+      }
+      // 已有事实的后续更新可以追溯到已转正并删除的原计划;首次转正必须有计划文件。
+      if (!planExists && (existing == null || item.removePlan === true)) {
+        errors.push(`${where}.planPath 对应的计划对象不存在或已被移除`)
+      }
+    }
+
+    changes.push({
+      planPath,
+      factPath,
+      content,
+      decision,
+      ...(expectedHash ? { expectedHash } : {}),
+      ...(resolution ? { resolution } : {}),
+      removePlan: item.removePlan === true,
+    })
+  }
+  return { ok: errors.length === 0, errors, changes }
+}
+
+export function isFactPath(value) {
+  const rel = normalizePosixRelative(value)
+  if (!rel || !rel.endsWith('.md')) return false
+  const parts = rel.split('/')
+  return parts.length >= 3 && parts[0] === '定稿' && parts[1] === '设定'
+}
+
+export function factContentHash(content) {
+  return 'sha256:' + sha256(content)
+}
+
+function normalizeExpectedHash(value) {
+  if (value == null || value === '') return ''
+  const text = String(value).trim().toLowerCase().replace(/^sha256:/, '')
+  return /^[0-9a-f]{64}$/.test(text) ? text : ''
+}

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

@@ -330,15 +330,16 @@ export async function sceneCandidates(packageRoot, texts) {
  * 旧细纲声明解析器在章级调用切换前保留;下一阶段一次性改为十维目标标签。
  */
 export function parseOutlineDeclarations(outline) {
-  const out = { 节拍: '', 钩子: '', 场景: [] }
+  const out = { 节拍: '', 钩子: '', 场景: [], 对象: [] }
   for (const line of String(outline || '').split('\n')) {
     const text = line.trim()
-    const match = text.match(/^(本章节拍|章尾钩子|本章场景)[::]\s*(.*)$/)
+    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 out.场景 = normalizeList(value)
+    else if (match[1] === '本章场景') out.场景 = normalizeList(value)
+    else out.对象 = normalizeList(value)
   }
   return out
 }

+ 55 - 0
v7/src/prep/index.js

@@ -9,6 +9,7 @@ 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'
 
 /**
  * 备料:组装 工作区/本章写作材料.md(spec §8 step3,默认精准片段)。
@@ -111,6 +112,17 @@ export async function prepareChapterMaterials(ctx, { chapterNum }) {
 
     const 契约段 = `## 作品契约(本章所需)\n${contract.content.replace(/^## /gm, '### ')}`
 
+    const declarations = parseOutlineDeclarations(outlineText)
+    const designObjects = await assembleDesignObjects(
+      repoPath,
+      declarations.对象,
+      facts.removedPlans || new Set()
+    )
+    if (!designObjects.ok) {
+      return { ok: false, filePath: '', content: '', error: `备料停止:${designObjects.error}` }
+    }
+    const stagedFactObjects = assembleStagedFactObjects(facts.factChanges)
+
     // 细纲声明命中的知识切片(声明即路由;查不到表注入声明文本本身,spec §7)
     const 知识切片 = ctx.packageRoot ? await declaredKnowledge(ctx.packageRoot, outlineText) : ''
 
@@ -150,6 +162,8 @@ export async function prepareChapterMaterials(ctx, { chapterNum }) {
       '',
       契约段,
       '',
+      ...(designObjects.content ? [designObjects.content, ''] : []),
+      ...(stagedFactObjects ? [stagedFactObjects, ''] : []),
       ...(知识切片 ? [知识切片, ''] : []),
       `## 反复读清单\n${反复读}`,
       '',
@@ -168,6 +182,47 @@ export async function prepareChapterMaterials(ctx, { chapterNum }) {
   }
 }
 
+function assembleStagedFactObjects(changes) {
+  if (!(changes instanceof Map) || !changes.size) return ''
+  const parts = [
+    '## 批内待转正事实',
+    '',
+    '> 以下事实来自本章之前已过审的批内章节,尚未正式入档,但本章须按其保持一致。',
+  ]
+  for (const [factPath, change] of changes) {
+    parts.push('', `### 第 ${change.章号} 章:${factPath}`, '', change.content)
+  }
+  return parts.join('\n')
+}
+
+async function assembleDesignObjects(repoPath, references, excludePaths) {
+  if (!references.length) return { ok: true, content: '', error: '' }
+  const result = await new DesignReader(repoPath).readMany(references, { excludePaths })
+  if (!result.ok) return { ok: false, content: '', error: result.error }
+  const parts = [
+    '## 本章计划对象(尚未转正)',
+    '',
+    '> 以下内容用于保持设计一致,不代表正文已经建立这些事实。',
+  ]
+  for (const object of result.objects) {
+    const type = object.对象类型 ? `/${object.对象类型}` : ''
+    parts.push(
+      '',
+      `### ${object.ID} ${object.正名 || object.名称}(${object.分类}${type})`,
+      '',
+      '#### 本书设计',
+      object.sections.get('本书设计'),
+      '',
+      '#### 一致性边界',
+      object.sections.get('一致性边界')
+    )
+  }
+  if (result.missing.length) {
+    parts.push('', `- 未找到计划对象:${result.missing.join('、')}。按本章文本继续,不要求临时对象补档。`)
+  }
+  return { ok: true, content: parts.join('\n'), error: '' }
+}
+
 // 批内暂存章的结尾片段(与 ChapterReader.readTail 同口径:正文末 N 个字符)
 function charTail(body, n) {
   return [...String(body)].slice(-n).join('').trim()

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

@@ -14,6 +14,7 @@ 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'
 
 const 兼容声明 = '本次使用兼容模式(单上下文顺序审稿),审稿隔离度低于完整两审模式。'
 const 完整声明 = '完整两审模式(事实审查/编辑审各自独立上下文)。'
@@ -80,6 +81,15 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
     }
 
     const 知识审查 = await assembleKnowledgeChecks(ctx, outlineText)
+    const designDeclarations = parseOutlineDeclarations(outlineText).对象
+    const designObjects = designDeclarations.length
+      ? await new DesignReader(repoPath).readMany(designDeclarations, {
+          excludePaths: facts.removedPlans || new Set(),
+        })
+      : { ok: true, objects: [], missing: [], error: '' }
+    if (!designObjects.ok) {
+      return { ok: false, input: null, error: `组装审稿输入停止:${designObjects.error}` }
+    }
 
     const status = overlayBookStatus(await assembleBookStatus(ctx), facts)
     const 当前卷 = status.ok ? status.data.当前卷 : 1
@@ -213,6 +223,28 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
         信息差候选,
         作品契约: contract.content,
         知识审查,
+        批内待转正事实: [...(facts.factChanges || new Map()).entries()].map(
+          ([factPath, change]) => ({
+            事实路径: factPath,
+            来源章: change.章号,
+            内容: change.content,
+          })
+        ),
+        本章计划对象: designObjects.objects.map((object) => ({
+          计划路径: object.path,
+          分类: object.分类,
+          ID: object.ID,
+          名称: object.名称,
+          ...(object.对象类型 ? { 对象类型: object.对象类型 } : {}),
+          ...(object.正名 ? { 正名: object.正名 } : {}),
+          ...(object.别名.length ? { 别名: object.别名 } : {}),
+          本书设计: object.sections.get('本书设计'),
+          一致性边界: object.sections.get('一致性边界'),
+          提醒: '这是计划对象,不是已经成立的正文事实。',
+        })),
+        ...(designObjects.missing.length
+          ? { 计划对象缺失提醒: `未找到:${designObjects.missing.join('、')}。按正文实际内容审查,不要求临时对象补档。` }
+          : {}),
       },
       error: '',
     }

+ 26 - 0
v7/src/staging/index.js

@@ -23,6 +23,8 @@ import {
   evaluateContractIssueSignals,
   readContractIssueHistory,
 } from '../knowledge/contract-issues.js'
+import { isFactPath, validateFactChanges } from '../knowledge/fact-changes.js'
+import { isDesignPath } from '../knowledge/design.js'
 
 /**
  * staging:待定稿批次(自动模式,spec §8.1)。批次真源 = 工作区/待定稿/ 下的文件,
@@ -152,6 +154,8 @@ export async function stagedFacts(repoPath, opts = {}) {
     characterUpdates: new Map(),
     timelineRows: [],
     secretWrites: [],
+    factChanges: new Map(),
+    removedPlans: new Set(),
     总字数: 0,
     warnings: [...batch.warnings],
   }
@@ -242,6 +246,17 @@ export async function stagedFacts(repoPath, opts = {}) {
     }
     for (const tr of payload.timelineRows || []) facts.timelineRows.push(tr)
     for (const s of payload.secretWrites || []) facts.secretWrites.push(s)
+    for (const change of payload.factChanges || []) {
+      if (!isFactPath(change?.factPath) || !isDesignPath(change?.planPath)) continue
+      if (!['无冲突', '作者已裁决'].includes(change.decision)) continue
+      if (typeof change.content !== 'string' || !change.content.trim()) continue
+      facts.factChanges.set(change.factPath, {
+        ...change,
+        章号: row.章号,
+        content: change.content,
+      })
+      if (change.removePlan === true) facts.removedPlans.add(change.planPath)
+    }
   }
   return facts
 }
@@ -332,6 +347,17 @@ export async function stageChapter(ctx, { chapterNum, payload }) {
     if (!reviewed.ok) return reviewed
     payload = reviewed.payload
 
+    const priorFacts = await stagedFacts(repoPath, { before: chapterNum })
+    const factValidation = await validateFactChanges(repoPath, payload.factChanges, {
+      factOverlay: priorFacts.factChanges,
+      removedPlans: priorFacts.removedPlans,
+    })
+    if (!factValidation.ok) {
+      return { ok: false, error: `事实转正停止:${factValidation.errors.join(';')}` }
+    }
+    if (factValidation.changes.length) payload.factChanges = factValidation.changes
+    else delete payload.factChanges
+
     const 标题 = payload.frontMatter.标题
     const dirName = `${String(chapterNum).padStart(4, '0')}-${sanitizeFileName(标题)}`
     const rel = (f) => path.join(BATCH_DIR, dirName, f)

+ 28 - 0
v7/src/state-machine/detectors.js

@@ -6,6 +6,12 @@ import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
 import { createGit } from '../finalize/git.js'
 import { readBatch } from '../staging/index.js'
 import { validateWorkContract } from '../knowledge/contract.js'
+import {
+  DESIGN_CLASSES,
+  DESIGN_ROOT,
+  validateDesignContent,
+  validateDesignRegistry,
+} from '../knowledge/design.js'
 
 /** 序2/relink/goto 共用的「跟踪面」(决策 D6):手改检测、relink 补登范围、goto 脏树拒绝
  * 三处同源判定,防双写漂移。文风铁律与 book.yaml 的序0 修复回写由此走序2 补登入档。 */
@@ -46,6 +52,28 @@ export async function detectParseFailures(repoPath) {
     }
   }
 
+  const designObjects = []
+  for (const classification of DESIGN_CLASSES) {
+    const sub = `${DESIGN_ROOT}/${classification}`
+    const base = path.join(repoPath, ...sub.split('/'))
+    let files
+    try {
+      files = await fs.readdir(base)
+    } catch {
+      continue
+    }
+    for (const file of files) {
+      if (!file.endsWith('.md')) continue
+      const content = await fs.readFile(path.join(base, file), 'utf8')
+      const parsed = validateDesignContent(content, { classification })
+      if (!parsed.ok) failures.push({ file: `${sub}/${file}`, error: parsed.errors.join(';') })
+      else designObjects.push({ ...parsed.data, path: `${sub}/${file}` })
+    }
+  }
+  for (const error of validateDesignRegistry(designObjects)) {
+    failures.push({ file: DESIGN_ROOT, error })
+  }
+
   // book.yaml:存在但解析失败才算(缺失归序 1 建书态)
   try {
     await fs.access(path.join(repoPath, 'book.yaml'))

+ 52 - 0
v7/src/state-machine/persist.js

@@ -15,6 +15,7 @@ import {
   validateCreateBookPayload,
   validateWorkContract,
 } from '../knowledge/contract.js'
+import { isDesignPath, validateDesignContent } from '../knowledge/design.js'
 
 /**
  * AI 态产物回流落盘(M3 落盘,AI 不碰文件)。AI 提交结构化 DTO,本层映射到路径写出。
@@ -153,6 +154,52 @@ export async function persistWorkContract(
   }
 }
 
+/** 已确认的计划对象保存/放弃:源文件变更与 Git 提交同一原子边界。 */
+export async function persistDesignChanges(ctx, plan, opts = {}) {
+  const git = opts.git || createGit(ctx.repoPath)
+  const writes = plan.writes.map(({ path: file, content }) => ({ path: file, content }))
+  const deletes = [...plan.deletes]
+  if (!writes.length && !deletes.length) {
+    return {
+      ok: true,
+      written: [],
+      deleted: [],
+      commitHash: '',
+      unchanged: true,
+      error: '',
+    }
+  }
+
+  let written = []
+  const deleted = []
+  const touched = [...new Set([...writes.map((item) => item.path), ...deletes])]
+  try {
+    if (writes.length) written = await writeAtomicBatch(ctx.repoPath, writes)
+    for (const rel of deletes) {
+      await fs.rm(path.join(ctx.repoPath, ...rel.split('/')))
+      deleted.push(rel)
+    }
+    await git.ensureIdentity()
+    await git.add(touched)
+    const actions = []
+    if (plan.savedIds.length) actions.push(`保存 ${plan.savedIds.join('、')}`)
+    if (plan.abandonedIds.length) actions.push(`放弃 ${plan.abandonedIds.join('、')}`)
+    const commitHash = await git.commit(`fix(设计): ${actions.join(';')}`)
+    return { ok: true, written, deleted, commitHash, unchanged: false, error: '' }
+  } catch (err) {
+    for (const rel of touched) await git.restore([rel])
+    await git.clean(written)
+    return {
+      ok: false,
+      written: [],
+      deleted: [],
+      commitHash: '',
+      unchanged: false,
+      error: `故事对象设计落盘失败,已回滚本次变更:${err.message}`,
+    }
+  }
+}
+
 /** 序4 卷复盘 → 定稿/摘要/卷摘要/第NN卷.md + 大纲/卷纲/第{卷号+1}卷.md(+ 可选伏笔条目)。
  * 产物随手 commit(`vol(NN): 复盘与下卷规划`,spec §9)——与建书同理,不 commit 会让 next 误触序2 手改检测;
  * 新伏笔条目改了源,同步刷缓存,否则 list-threads/备料/两审要等下次定稿才看得见。 */
@@ -205,6 +252,11 @@ export async function persistVolumeReview(ctx, { 卷号, 卷摘要, 下卷卷纲
  */
 function validateRepairContent(file, content) {
   const rel = String(file).replaceAll('\\', '/')
+  if (isDesignPath(rel)) {
+    const classification = rel.split('/')[2]
+    const r = validateDesignContent(content, { classification })
+    return r.ok ? { ok: true } : { ok: false, error: r.errors.join(';') }
+  }
   if (rel === WORK_CONTRACT_PATH) {
     const r = validateWorkContract(content)
     return r.ok ? { ok: true } : { ok: false, error: r.errors.join(';') }

+ 98 - 0
v7/src/storage/adapters/DesignReader.js

@@ -0,0 +1,98 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import {
+  DESIGN_CLASSES,
+  DESIGN_ROOT,
+  isDesignPath,
+  validateDesignRegistry,
+  validateDesignContent,
+} from '../../knowledge/design.js'
+
+export class DesignReader {
+  constructor(repoPath) {
+    this.repoPath = repoPath
+  }
+
+  async list() {
+    const objects = []
+    const errors = []
+    for (const classification of DESIGN_CLASSES) {
+      const dir = path.join(this.repoPath, ...DESIGN_ROOT.split('/'), classification)
+      let files
+      try {
+        files = (await fs.readdir(dir, { withFileTypes: true }))
+          .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
+          .map((entry) => entry.name)
+          .sort(compareCodePoints)
+      } catch (err) {
+        if (err.code !== 'ENOENT') errors.push(`${classification}计划目录读取失败:${err.message}`)
+        continue
+      }
+      for (const file of files) {
+        const rel = path.posix.join(DESIGN_ROOT, classification, file)
+        try {
+          const content = await fs.readFile(path.join(this.repoPath, ...rel.split('/')), 'utf8')
+          const parsed = validateDesignContent(content, { classification })
+          if (!parsed.ok) {
+            errors.push(`${rel}:${parsed.errors.join(';')}`)
+            continue
+          }
+          objects.push({ ...parsed.data, path: rel })
+        } catch (err) {
+          errors.push(`${rel} 读取失败:${err.message}`)
+        }
+      }
+    }
+    errors.push(...validateDesignRegistry(objects))
+    return { ok: errors.length === 0, objects, errors }
+  }
+
+  async read(reference) {
+    const result = await this.readMany([reference])
+    if (!result.ok) return { ok: false, data: null, error: result.error }
+    if (!result.objects.length) {
+      return { ok: false, data: null, error: `未找到计划对象「${String(reference || '').trim()}」` }
+    }
+    return { ok: true, data: result.objects[0], error: '' }
+  }
+
+  async readMany(references, { excludePaths = new Set() } = {}) {
+    const listed = await this.list()
+    if (!listed.ok) return { ok: false, objects: [], missing: [], error: listed.errors.join(';') }
+    const objects = []
+    const missing = []
+    const seen = new Set()
+    for (const raw of Array.isArray(references) ? references : []) {
+      const reference = String(raw || '').trim()
+      if (!reference) continue
+      const object = listed.objects.find(
+        (item) => item.ID === reference || item.名称 === reference || item.正名 === reference
+      )
+      if (!object || excludePaths.has(object.path)) {
+        missing.push(reference)
+        continue
+      }
+      if (!seen.has(object.path)) objects.push(object)
+      seen.add(object.path)
+    }
+    return { ok: true, objects, missing, error: '' }
+  }
+
+  async readPath(planPath) {
+    if (!isDesignPath(planPath)) return { ok: false, data: null, error: '计划对象路径不合法' }
+    const parts = planPath.split('/')
+    try {
+      const content = await fs.readFile(path.join(this.repoPath, ...parts), 'utf8')
+      const parsed = validateDesignContent(content, { classification: parts[2] })
+      return parsed.ok
+        ? { ok: true, data: { ...parsed.data, path: planPath }, error: '' }
+        : { ok: false, data: null, error: parsed.errors.join(';') }
+    } catch (err) {
+      return { ok: false, data: null, error: `计划对象不存在:${err.message}` }
+    }
+  }
+}
+
+function compareCodePoints(a, b) {
+  return a < b ? -1 : a > b ? 1 : 0
+}

+ 1 - 0
v7/src/storage/index.js

@@ -14,3 +14,4 @@ export { OutlineReader } from './adapters/OutlineReader.js'
 export { BookConfigReader } from './adapters/BookConfigReader.js'
 export { SummaryWriter } from './adapters/SummaryWriter.js'
 export { ContractReader } from './adapters/ContractReader.js'
+export { DesignReader } from './adapters/DesignReader.js'

+ 76 - 0
v7/test/commands/persist-design.test.js

@@ -0,0 +1,76 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { run as persistDesign } from '../../src/commands/persist-design.js'
+import { run as readDesign } from '../../src/commands/read-design.js'
+import { makeGitBook } from '../state-machine/_helper.js'
+import { designFixture } from '../knowledge/_design-fixture.js'
+
+async function writePayload(root, data) {
+  const file = path.join(root, '工作区', '设计.json')
+  await fs.mkdir(path.dirname(file), { recursive: true })
+  await fs.writeFile(file, JSON.stringify(data), 'utf8')
+  return file
+}
+
+test('persist-design:创建、改名和放弃计划对象分别入档,read-design 可按 ID 精读', async () => {
+  const { ctx, root, git, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n类型: 玄幻\n',
+  })
+  try {
+    let file = await writePayload(root, {
+      作者已确认: true,
+      对象: [{ 分类: '人物', 内容: designFixture() }],
+    })
+    const created = await persistDesign([], { file }, ctx)
+    assert.equal(created.ok, true, created.error)
+    await fs.access(path.join(root, '大纲', '创作设计', '人物', 'CHAR-001-林晚.md'))
+
+    const read = await readDesign(['CHAR-001'], {}, ctx)
+    assert.equal(read.ok, true, read.error)
+    const dto = JSON.parse(read.output)
+    assert.equal(dto.正名, '林晚')
+    assert.match(dto.本书设计, /外怯内韧/)
+    assert.equal(dto.计划路径, '大纲/创作设计/人物/CHAR-001-林晚.md')
+
+    file = await writePayload(root, {
+      作者已确认: true,
+      对象: [{
+        分类: '人物',
+        内容: designFixture({ canonical: '林晚舟', alias: '晚晚' }),
+      }],
+    })
+    const renamed = await persistDesign([], { file }, ctx)
+    assert.equal(renamed.ok, true, renamed.error)
+    await fs.access(path.join(root, '大纲', '创作设计', '人物', 'CHAR-001-林晚舟.md'))
+    await assert.rejects(() => fs.access(path.join(root, '大纲', '创作设计', '人物', 'CHAR-001-林晚.md')))
+
+    file = await writePayload(root, { 作者已确认: true, 放弃: ['CHAR-001'] })
+    const abandoned = await persistDesign([], { file }, ctx)
+    assert.equal(abandoned.ok, true, abandoned.error)
+    await assert.rejects(() => fs.access(path.join(root, '大纲', '创作设计', '人物', 'CHAR-001-林晚舟.md')))
+    assert.match((await git(['log', '-1', '--format=%s'])).stdout, /^fix\(设计\): 放弃 CHAR-001/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('persist-design:与定稿名册重名时零写入', async () => {
+  const { ctx, root, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n类型: 玄幻\n',
+    '定稿/设定/名册.md': '| 正名 | 别名 | 类型 | 首现章 |\n|---|---|---|---|\n| 林晚 | 晚晚 | 角色 | 1 |\n',
+  })
+  try {
+    const file = await writePayload(root, {
+      作者已确认: true,
+      对象: [{ 分类: '人物', 内容: designFixture() }],
+    })
+    const result = await persistDesign([], { file }, ctx)
+    assert.equal(result.ok, false)
+    assert.match(result.error, /林晚|晚晚/)
+    await assert.rejects(() => fs.access(path.join(root, '大纲', '创作设计', '人物')))
+  } finally {
+    await cleanup()
+  }
+})

+ 98 - 0
v7/test/finalize/fact-changes.test.js

@@ -0,0 +1,98 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { finalizeChapter } from '../../src/finalize/index.js'
+import { makeGitBook } from '../state-machine/_helper.js'
+import { designFixture } from '../knowledge/_design-fixture.js'
+import { FACT_PATH, PLAN_PATH, factFixture } from '../knowledge/_fact-fixture.js'
+
+function payload(decision = '无冲突') {
+  return {
+    chapterNum: 1,
+    frontMatter: {
+      章号: 1,
+      标题: '初见林晚',
+      卷: 1,
+      字数: 100,
+      章定位: '推进',
+      钩子: '危机钩-强',
+      情绪定位: '铺垫',
+    },
+    body: '林晚第一次正式登场。',
+    factChanges: [{
+      planPath: PLAN_PATH,
+      factPath: FACT_PATH,
+      content: factFixture(),
+      decision,
+      removePlan: true,
+    }],
+  }
+}
+
+async function repo() {
+  return makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n类型: 玄幻\n',
+    [PLAN_PATH]: designFixture(),
+  })
+}
+
+test('finalize:章节、事实写入和计划删除进入同一提交', async () => {
+  const { ctx, root, git, cleanup } = await repo()
+  try {
+    const result = await finalizeChapter(ctx, payload())
+    assert.equal(result.ok, true, result.error)
+    await fs.access(path.join(root, ...FACT_PATH.split('/')))
+    await fs.access(path.join(root, '定稿', '正文', '0001-初见林晚.md'))
+    await assert.rejects(() => fs.access(path.join(root, ...PLAN_PATH.split('/'))))
+    const changed = (
+      await git(['-c', 'core.quotepath=false', 'show', '--name-status', '--format='])
+    ).stdout.replaceAll('\\', '/')
+    assert.match(changed, /大纲\/创作设计\/人物\/CHAR-001-林晚.md/)
+    assert.match(changed, /定稿\/设定\/角色\/林晚.md/)
+    assert.match(changed, /定稿\/正文\/0001-初见林晚.md/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('finalize:冲突零写入;写入后故障会恢复计划并清掉章节与事实', async () => {
+  const conflictRepo = await repo()
+  try {
+    const blocked = await finalizeChapter(conflictRepo.ctx, payload('冲突'))
+    assert.equal(blocked.ok, false)
+    assert.match(blocked.error, /作者裁决/)
+    await fs.access(path.join(conflictRepo.root, ...PLAN_PATH.split('/')))
+    await assert.rejects(() => fs.access(path.join(conflictRepo.root, ...FACT_PATH.split('/'))))
+    await assert.rejects(() => fs.access(path.join(conflictRepo.root, '定稿', '正文', '0001-初见林晚.md')))
+  } finally {
+    await conflictRepo.cleanup()
+  }
+
+  const faultRepo = await repo()
+  try {
+    const failed = await finalizeChapter(faultRepo.ctx, payload(), { faultAfterWrite: true })
+    assert.equal(failed.ok, false)
+    await fs.access(path.join(faultRepo.root, ...PLAN_PATH.split('/')))
+    await assert.rejects(() => fs.access(path.join(faultRepo.root, ...FACT_PATH.split('/'))))
+    await assert.rejects(() => fs.access(path.join(faultRepo.root, '定稿', '正文', '0001-初见林晚.md')))
+    assert.equal((await faultRepo.git(['status', '--porcelain'])).stdout.trim(), '')
+  } finally {
+    await faultRepo.cleanup()
+  }
+})
+
+test('finalize:factChanges 与旧事实写入字段命中同一路径时零写入', async () => {
+  const { ctx, root, cleanup } = await repo()
+  try {
+    const input = payload()
+    input.characterUpdates = [{ name: '林晚', updates: { 境界: '练气四层' } }]
+    const result = await finalizeChapter(ctx, input)
+    assert.equal(result.ok, false)
+    assert.match(result.error, /同时出现在 factChanges/)
+    await fs.access(path.join(root, ...PLAN_PATH.split('/')))
+    await assert.rejects(() => fs.access(path.join(root, ...FACT_PATH.split('/'))))
+  } finally {
+    await cleanup()
+  }
+})

+ 21 - 0
v7/test/knowledge/_design-fixture.js

@@ -0,0 +1,21 @@
+export const designFixture = ({
+  id = 'CHAR-001',
+  name = '主角计划',
+  canonical = '林晚',
+  alias = '晚晚',
+} = {}) => [
+  '---',
+  `ID: ${id}`,
+  `名称: ${name}`,
+  '对象类型: 角色',
+  `正名: ${canonical}`,
+  '别名:',
+  `  - ${alias}`,
+  '---',
+  '## 本书设计',
+  '外怯内韧,靠调查推进主线。',
+  '## 一致性边界',
+  '不会牺牲无辜换取力量。',
+  '## 知识依据',
+  '人物设计方法经本书适配。',
+].join('\n')

+ 4 - 0
v7/test/knowledge/_fact-fixture.js

@@ -0,0 +1,4 @@
+export const PLAN_PATH = '大纲/创作设计/人物/CHAR-001-林晚.md'
+export const FACT_PATH = '定稿/设定/角色/林晚.md'
+export const factFixture = (realm = '练气三层') =>
+  `---\n姓名: 林晚\n境界: ${realm}\n---\n## 设定\n已在正文成立。\n`

+ 67 - 0
v7/test/knowledge/design-pipeline.test.js

@@ -0,0 +1,67 @@
+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 { assembleReviewInput } from '../../src/review/index.js'
+import { tempBookCtx } from '../commands/_helper.js'
+import { designFixture } from './_design-fixture.js'
+import { PLAN_PATH } from './_fact-fixture.js'
+
+test('本章对象声明只加载命中计划,并明确计划不等于事实;缺失只提醒', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    const planFile = path.join(ctx.repoPath, ...PLAN_PATH.split('/'))
+    await fs.mkdir(path.dirname(planFile), { recursive: true })
+    await fs.writeFile(planFile, designFixture(), 'utf8')
+    await fs.writeFile(
+      path.join(ctx.repoPath, '工作区', '细纲.md'),
+      '## 本章提案\n本章对象:CHAR-001、临时路人\n## 本章要写到的事\n林晚初次登场。\n',
+      'utf8'
+    )
+
+    const materials = await prepareChapterMaterials(ctx, { chapterNum: 3 })
+    assert.equal(materials.ok, true, materials.error)
+    assert.match(materials.content, /## 本章计划对象(尚未转正)/)
+    assert.match(materials.content, /CHAR-001 林晚/)
+    assert.match(materials.content, /外怯内韧/)
+    assert.match(materials.content, /未找到计划对象:临时路人/)
+
+    const draft = path.join(ctx.repoPath, '工作区', '草稿-A.md')
+    await fs.writeFile(draft, '林晚推门而入。', 'utf8')
+    const review = await assembleReviewInput(ctx, { chapterNum: 3, draftPath: draft })
+    assert.equal(review.ok, true, review.error)
+    assert.equal(review.input.本章计划对象.length, 1)
+    assert.equal(review.input.本章计划对象[0].计划路径, PLAN_PATH)
+    assert.match(review.input.本章计划对象[0].提醒, /不是已经成立/)
+    assert.match(review.input.计划对象缺失提醒, /临时路人/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('细纲未声明本章对象时不扫描或全量注入计划目录', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    const broken = path.join(ctx.repoPath, '大纲', '创作设计', '人物', '坏对象.md')
+    await fs.mkdir(path.dirname(broken), { recursive: true })
+    await fs.writeFile(broken, '不是合法计划对象。', 'utf8')
+    await fs.writeFile(
+      path.join(ctx.repoPath, '工作区', '细纲.md'),
+      '## 本章要写到的事\n一次性路人递来一封信。\n',
+      'utf8'
+    )
+    const materials = await prepareChapterMaterials(ctx, { chapterNum: 3 })
+    assert.equal(materials.ok, true, materials.error)
+    assert.doesNotMatch(materials.content, /本章计划对象/)
+
+    const draft = path.join(ctx.repoPath, '工作区', '草稿-A.md')
+    await fs.writeFile(draft, '路人递来信件后离开。', 'utf8')
+    const review = await assembleReviewInput(ctx, { chapterNum: 3, draftPath: draft })
+    assert.equal(review.ok, true, review.error)
+    assert.deepEqual(review.input.本章计划对象, [])
+    assert.equal(review.input.计划对象缺失提醒, undefined)
+  } finally {
+    await cleanup()
+  }
+})

+ 74 - 0
v7/test/knowledge/design.test.js

@@ -0,0 +1,74 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import {
+  buildDesignChangePlan,
+  validateDesignContent,
+  validateDesignPayload,
+  validateDesignRegistry,
+} from '../../src/knowledge/design.js'
+import { designFixture as design } from './_design-fixture.js'
+
+test('计划对象最小 schema:命名附着对象,必需小节与受控类型通过', () => {
+  const result = validateDesignContent(design(), { classification: '人物' })
+  assert.equal(result.ok, true, result.errors.join(';'))
+  assert.equal(result.data.ID, 'CHAR-001')
+  assert.equal(result.data.正名, '林晚')
+  assert.deepEqual(result.data.别名, ['晚晚'])
+  assert.equal(result.data.path, '大纲/创作设计/人物/CHAR-001-林晚.md')
+})
+
+test('计划对象拒绝嵌套字段、非法类型和缺一致性边界', () => {
+  const content = design()
+    .replace('对象类型: 角色', '对象类型: 物品\n额外:\n  子项: 值')
+    .replace('## 一致性边界', '## 其他边界')
+  const result = validateDesignContent(content, { classification: '人物' })
+  assert.equal(result.ok, false)
+  assert.ok(result.errors.some((error) => error.includes('嵌套对象')))
+  assert.ok(result.errors.some((error) => error.includes('允许范围')))
+  assert.ok(result.errors.some((error) => error.includes('一致性边界')))
+})
+
+test('对象 ID 的英文字母必须大写,避免 Windows 大小写路径碰撞', () => {
+  const result = validateDesignContent(design({ id: 'char-001' }), { classification: '人物' })
+  assert.equal(result.ok, false)
+  assert.ok(result.errors.some((error) => error.includes('必须大写')))
+})
+
+test('计划变更在落盘前检查 ID、正名与别名冲突', () => {
+  const first = validateDesignContent(design(), { classification: '人物' }).data
+  const second = validateDesignContent(
+    design({ id: 'CHAR-002', name: '搭档计划', canonical: '沈砚', alias: '晚晚' }),
+    { classification: '人物' }
+  ).data
+  const plan = buildDesignChangePlan([], [first, second], [], [])
+  assert.equal(plan.ok, false)
+  assert.ok(plan.errors.some((error) => error.includes('晚晚')))
+
+  const reserved = buildDesignChangePlan([], [first], [], [{ 名称: '林晚', 归属: '林晚' }])
+  assert.equal(reserved.ok, false)
+  assert.ok(reserved.errors.some((error) => error.includes('定稿名册')))
+})
+
+test('现有计划目录的重复 ID 在读取前即可发现', () => {
+  const first = validateDesignContent(design(), { classification: '人物' }).data
+  const duplicate = {
+    ...validateDesignContent(
+      design({ name: '另一份计划', canonical: '沈砚', alias: '阿砚' }),
+      { classification: '人物' }
+    ).data,
+    path: '大纲/创作设计/人物/CHAR-001-沈砚.md',
+  }
+  const errors = validateDesignRegistry([first, duplicate])
+  assert.ok(errors.some((error) => error.includes('ID「CHAR-001」')))
+})
+
+test('persist-design payload 必须由作者确认,保存与放弃不能指向同一 ID', () => {
+  const result = validateDesignPayload({
+    作者已确认: false,
+    对象: [{ 分类: '人物', 内容: design() }],
+    放弃: ['CHAR-001'],
+  })
+  assert.equal(result.ok, false)
+  assert.ok(result.errors.some((error) => error.includes('作者确认')))
+  assert.ok(result.errors.some((error) => error.includes('既保存又放弃')))
+})

+ 123 - 0
v7/test/knowledge/fact-changes.test.js

@@ -0,0 +1,123 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import os from 'node:os'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import {
+  factContentHash,
+  validateFactChanges,
+} from '../../src/knowledge/fact-changes.js'
+import { designFixture } from './_design-fixture.js'
+import { FACT_PATH, PLAN_PATH, factFixture as fact } from './_fact-fixture.js'
+
+async function fixture() {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-fact-validation-'))
+  const plan = path.join(root, ...PLAN_PATH.split('/'))
+  await fs.mkdir(path.dirname(plan), { recursive: true })
+  await fs.writeFile(plan, designFixture(), 'utf8')
+  return { root, cleanup: () => fs.rm(root, { recursive: true, force: true }) }
+}
+
+test('首次事实转正:计划存在、无冲突且路径安全时通过', async () => {
+  const { root, cleanup } = await fixture()
+  try {
+    const result = await validateFactChanges(root, [{
+      planPath: PLAN_PATH,
+      factPath: FACT_PATH,
+      content: fact(),
+      decision: '无冲突',
+      removePlan: true,
+    }])
+    assert.equal(result.ok, true, result.errors.join(';'))
+    assert.equal(result.changes[0].removePlan, true)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('冲突、歧义和未说明的作者裁决全部在写盘前停止', async () => {
+  const { root, cleanup } = await fixture()
+  try {
+    for (const decision of ['冲突', '歧义', '作者已裁决']) {
+      const result = await validateFactChanges(root, [{
+        planPath: PLAN_PATH,
+        factPath: FACT_PATH,
+        content: fact(),
+        decision,
+        removePlan: true,
+      }])
+      assert.equal(result.ok, false)
+      assert.ok(result.errors.some((error) => error.includes(decision === '作者已裁决' ? 'resolution' : decision)))
+    }
+  } finally {
+    await cleanup()
+  }
+})
+
+test('覆盖既有事实必须匹配哈希;转正后计划已删仍可更新既有事实', async () => {
+  const { root, cleanup } = await fixture()
+  try {
+    const factFile = path.join(root, ...FACT_PATH.split('/'))
+    const current = fact()
+    await fs.mkdir(path.dirname(factFile), { recursive: true })
+    await fs.writeFile(factFile, current, 'utf8')
+    await fs.rm(path.join(root, ...PLAN_PATH.split('/')))
+
+    const missing = await validateFactChanges(root, [{
+      planPath: PLAN_PATH,
+      factPath: FACT_PATH,
+      content: fact('练气四层'),
+      decision: '作者已裁决',
+      resolution: '作者确认本章突破成立',
+      removePlan: false,
+    }])
+    assert.ok(missing.errors.some((error) => error.includes('expectedHash')))
+
+    const drift = await validateFactChanges(root, [{
+      planPath: PLAN_PATH,
+      factPath: FACT_PATH,
+      content: fact('练气四层'),
+      decision: '作者已裁决',
+      resolution: '作者确认本章突破成立',
+      expectedHash: 'sha256:' + '0'.repeat(64),
+      removePlan: false,
+    }])
+    assert.ok(drift.errors.some((error) => error.includes('不一致')))
+
+    const valid = await validateFactChanges(root, [{
+      planPath: PLAN_PATH,
+      factPath: FACT_PATH,
+      content: fact('练气四层'),
+      decision: '作者已裁决',
+      resolution: '作者确认本章突破成立',
+      expectedHash: factContentHash(current),
+      removePlan: false,
+    }])
+    assert.equal(valid.ok, true, valid.errors.join(';'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('路径逃逸和同批重复目标被拒绝', async () => {
+  const { root, cleanup } = await fixture()
+  try {
+    const change = {
+      planPath: PLAN_PATH,
+      factPath: FACT_PATH,
+      content: fact(),
+      decision: '无冲突',
+      removePlan: true,
+    }
+    const result = await validateFactChanges(root, [
+      { ...change, factPath: '定稿/设定/../../book.yaml' },
+      change,
+      change,
+    ])
+    assert.equal(result.ok, false)
+    assert.ok(result.errors.some((error) => error.includes('factPath')))
+    assert.ok(result.errors.some((error) => error.includes('重复')))
+  } finally {
+    await cleanup()
+  }
+})

+ 102 - 0
v7/test/staging/fact-changes.test.js

@@ -0,0 +1,102 @@
+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 { assembleReviewInput } from '../../src/review/index.js'
+import { finalizeBatch, stageChapter, stagedFacts } from '../../src/staging/index.js'
+import { factContentHash } from '../../src/knowledge/fact-changes.js'
+import { makeGitBook, chapter } from '../state-machine/_helper.js'
+import { writeReviewArtifacts } from './_helper.js'
+import { designFixture } from '../knowledge/_design-fixture.js'
+import { FACT_PATH, PLAN_PATH, factFixture } from '../knowledge/_fact-fixture.js'
+
+function chapterPayload(num, factChanges) {
+  return {
+    frontMatter: {
+      章号: num,
+      标题: `第${num}章`,
+      卷: 1,
+      书内时间: `春月初${num}`,
+      字数: 100,
+      章定位: '推进',
+      钩子: '危机钩-强',
+      情绪定位: '铺垫',
+      伏笔: ['推进 伏笔-001'],
+    },
+    body: `第${num}章正文。`,
+    factChanges,
+    workspaceFiles: [],
+  }
+}
+
+test('批内事实叠加:后章看到前章待转正事实,并可按前章哈希继续更新', async () => {
+  const initialFact = factFixture()
+  const updatedFact = factFixture('练气四层')
+  const { ctx, root, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n类型: 玄幻\n连写批次大小: 8\n',
+    '定稿/正文/0001-一.md': chapter(1),
+    '定稿/正文/0002-二.md': chapter(2),
+    '大纲/卷纲/第01卷.md': '# 第01卷\n林晚登场并突破。\n',
+    [PLAN_PATH]: designFixture(),
+  })
+  try {
+    await writeReviewArtifacts(root, 3)
+    const staged3 = await stageChapter(ctx, {
+      chapterNum: 3,
+      payload: chapterPayload(3, [{
+        planPath: PLAN_PATH,
+        factPath: FACT_PATH,
+        content: initialFact,
+        decision: '无冲突',
+        removePlan: true,
+      }]),
+    })
+    assert.equal(staged3.ok, true, staged3.error)
+
+    const overlay = await stagedFacts(root, { before: 4 })
+    assert.equal(overlay.factChanges.get(FACT_PATH).content, initialFact)
+    assert.ok(overlay.removedPlans.has(PLAN_PATH))
+
+    await fs.writeFile(
+      path.join(root, '工作区', '细纲.md'),
+      '## 本章提案\n本章对象:CHAR-001\n## 本章要写到的事\n林晚突破。\n',
+      'utf8'
+    )
+    const materials = await prepareChapterMaterials(ctx, { chapterNum: 4 })
+    assert.equal(materials.ok, true, materials.error)
+    assert.match(materials.content, /## 批内待转正事实/)
+    assert.match(materials.content, /练气三层/)
+    assert.match(materials.content, /未找到计划对象:CHAR-001/)
+    assert.doesNotMatch(materials.content, /外怯内韧/)
+
+    const draftPath = path.join(root, '工作区', '草稿-A.md')
+    await fs.writeFile(draftPath, '---\n标题: 突破\n---\n林晚突破到练气四层。', 'utf8')
+    const review = await assembleReviewInput(ctx, { chapterNum: 4, draftPath })
+    assert.equal(review.ok, true, review.error)
+    assert.equal(review.input.批内待转正事实[0].事实路径, FACT_PATH)
+    assert.match(review.input.计划对象缺失提醒, /CHAR-001/)
+
+    await writeReviewArtifacts(root, 4)
+    const staged4 = await stageChapter(ctx, {
+      chapterNum: 4,
+      payload: chapterPayload(4, [{
+        planPath: PLAN_PATH,
+        factPath: FACT_PATH,
+        content: updatedFact,
+        decision: '无冲突',
+        expectedHash: factContentHash(initialFact),
+        removePlan: false,
+      }]),
+    })
+    assert.equal(staged4.ok, true, staged4.error)
+
+    const finalized = await finalizeBatch(ctx)
+    assert.equal(finalized.ok, true, finalized.error)
+    assert.deepEqual(finalized.已入档.map((item) => item.章号), [3, 4])
+    assert.match(await fs.readFile(path.join(root, ...FACT_PATH.split('/')), 'utf8'), /练气四层/)
+    await assert.rejects(() => fs.access(path.join(root, ...PLAN_PATH.split('/'))))
+  } finally {
+    await cleanup()
+  }
+})

+ 56 - 0
v7/test/state-machine/persist.test.js

@@ -8,12 +8,15 @@ import { promisify } from 'node:util'
 import {
   persistRepair,
   persistCreateBook,
+  persistDesignChanges,
   persistWorkContract,
   persistVolumeReview,
   persistDraftOutline,
 } from '../../src/state-machine/persist.js'
 import { makeGitBook, minimalCreateBookPayload, minimalWorkContract } from './_helper.js'
 import { validateWorkContract } from '../../src/knowledge/contract.js'
+import { designFixture } from '../knowledge/_design-fixture.js'
+import { buildDesignChangePlan, validateDesignContent } from '../../src/knowledge/design.js'
 
 async function tmpRepo() {
   const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-persist-'))
@@ -110,6 +113,35 @@ test('persistWorkContract:提交失败时三份源文件精确回滚', async (
   } finally { await cleanup() }
 })
 
+test('persistDesignChanges:改名提交失败时旧计划恢复、新路径清除', async () => {
+  const oldPath = '大纲/创作设计/人物/CHAR-001-林晚.md'
+  const oldContent = designFixture()
+  const { ctx, root, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n',
+    [oldPath]: oldContent,
+  })
+  try {
+    const existing = {
+      ...validateDesignContent(oldContent, { classification: '人物' }).data,
+      path: oldPath,
+    }
+    const nextContent = designFixture({ canonical: '林晚舟' })
+    const next = validateDesignContent(nextContent, { classification: '人物' }).data
+    const plan = buildDesignChangePlan([existing], [next], [], [])
+    const realGit = (await import('../../src/finalize/git.js')).createGit(root)
+    const result = await persistDesignChanges(ctx, plan, {
+      git: { ...realGit, async commit() { throw new Error('测试提交失败') } },
+    })
+    assert.equal(result.ok, false)
+    assert.equal((await read(root, oldPath)).replaceAll('\r\n', '\n'), oldContent)
+    await assert.rejects(() => read(root, next.path))
+    const { stdout } = await exec('git', ['status', '--porcelain'], { cwd: root })
+    assert.equal(stdout.trim(), '')
+  } finally {
+    await cleanup()
+  }
+})
+
 test('persistVolumeReview(序4)→ 写卷摘要 + 下卷卷纲 + vol commit(P1-1)', async () => {
   const { ctx, root, git, cleanup } = await makeGitBook({ 'book.yaml': 'spec_version: "7.0"\n书名: 测\n' })
   try {
@@ -211,3 +243,27 @@ test('persistRepair:名册修复内容仍是坏表格 → ok=false 不写', as
     assert.match(r.error, /名册/)
   } finally { await cleanup() }
 })
+
+test('persistRepair:计划对象按对象 schema 校验,不只检查普通 front matter', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  const target = '大纲/创作设计/人物/CHAR-001-林晚.md'
+  try {
+    const bad = await persistRepair(
+      ctx,
+      { repairs: [{ file: target, content: '---\nID: CHAR-001\n名称: 林晚\n---\n缺必需小节。' }] },
+      { allowedFiles: [target] }
+    )
+    assert.equal(bad.ok, false)
+    assert.match(bad.error, /本书设计|一致性边界/)
+
+    const good = await persistRepair(
+      ctx,
+      { repairs: [{ file: target, content: designFixture() }] },
+      { allowedFiles: [target] }
+    )
+    assert.equal(good.ok, true, good.error)
+    assert.match(await read(root, target), /一致性边界/)
+  } finally {
+    await cleanup()
+  }
+})

+ 14 - 0
v7/test/state-machine/router.test.js

@@ -9,6 +9,7 @@ import { CacheManager } from '../../src/cache/index.js'
 import { determineNextState } from '../../src/state-machine/index.js'
 import { runHealthCheck } from '../../src/health-check/index.js'
 import { minimalWorkContract } from './_helper.js'
+import { designFixture } from '../knowledge/_design-fixture.js'
 
 const execFileAsync = promisify(execFile)
 
@@ -94,6 +95,19 @@ test('序0:源文件解析失败 → 修复确认', async () => {
   }
 })
 
+test('序0:计划对象损坏 → 启动先进入修复确认', async () => {
+  const { ctx, cleanup } = await makeGitBook(healthyBook({
+    '大纲/创作设计/人物/CHAR-001-林晚.md': designFixture().replace('## 一致性边界', '## 边界缺失'),
+  }))
+  try {
+    const result = await determineNextState(ctx)
+    assert.equal(result.序, 0, JSON.stringify(result))
+    assert.ok(result.dto.failures.some((failure) => failure.file.includes('创作设计/人物')))
+  } finally {
+    await cleanup()
+  }
+})
+
 test('序2:定稿有未登记手改 → 提议补登', async () => {
   const { ctx, root, cleanup } = await makeGitBook(healthyBook())
   try {