Преглед на файлове

feat(v7): M7 P2——v6 双形态归一读取与映射纯函数

- read-v6.js:state.json 三形态(全量内联/5.4 精简/空损坏降级)、index.db 只读缺表容忍、
  正文三种历史命名归一、伏笔 status/tier 别名与埋设章多键名规范化(research Q13 十条兼容)
- transform.js:§10.3 映射表逐行——正文补 front matter(章定位=迁移、钩子 strong→强)、
  伏笔逐条成文件、名册/角色卡/时间线/摘要(summaries>db 列兜底)、卷纲并入剧情线、
  待校对三件(记忆清单/文风候选/实体变更史)、genre 码表、如实丢弃清单
- fixture 两形态(v6-inline 全量 / v6-sqlite 精简+测试内现场建 db,无二进制入库)
- 缺字段省略不编造;源 v6 零写入
lingfengQAQ преди 11 часа
родител
ревизия
8615d91a7f
променени са 27 файла, в които са добавени 1172 реда и са изтрити 0 реда
  1. 3 0
      .gitignore
  2. 333 0
      v7/src/migrate/read-v6.js
  3. 257 0
      v7/src/migrate/transform.js
  4. 1 0
      v7/test/fixtures/v6-inline/.story-system/MASTER_SETTING.json
  5. 16 0
      v7/test/fixtures/v6-inline/.webnovel/memory_scratchpad.json
  6. 6 0
      v7/test/fixtures/v6-inline/.webnovel/project_memory.json
  7. 1 0
      v7/test/fixtures/v6-inline/.webnovel/projection_log.jsonl
  8. 112 0
      v7/test/fixtures/v6-inline/.webnovel/state.json
  9. 18 0
      v7/test/fixtures/v6-inline/.webnovel/summaries/ch0001.md
  10. 0 0
      v7/test/fixtures/v6-inline/.webnovel/vectors.db
  11. 13 0
      v7/test/fixtures/v6-inline/大纲/总纲.md
  12. 7 0
      v7/test/fixtures/v6-inline/大纲/第1卷-时间线.md
  13. 10 0
      v7/test/fixtures/v6-inline/大纲/第1卷-详细大纲.md
  14. 5 0
      v7/test/fixtures/v6-inline/正文/第0001章-残剑出鞘.md
  15. 5 0
      v7/test/fixtures/v6-inline/正文/第0002章.md
  16. 5 0
      v7/test/fixtures/v6-inline/正文/第1卷/第003章-剑灵初醒.md
  17. 7 0
      v7/test/fixtures/v6-inline/设定集/世界观.md
  18. 5 0
      v7/test/fixtures/v6-inline/设定集/主角卡.md
  19. 28 0
      v7/test/fixtures/v6-sqlite/.webnovel/state.json
  20. 6 0
      v7/test/fixtures/v6-sqlite/大纲/总纲.md
  21. 7 0
      v7/test/fixtures/v6-sqlite/大纲/第1卷-详细大纲.md
  22. 3 0
      v7/test/fixtures/v6-sqlite/正文/第0001章-退潮.md
  23. 3 0
      v7/test/fixtures/v6-sqlite/正文/第0002章-保险单.md
  24. 3 0
      v7/test/fixtures/v6-sqlite/设定集/世界观.md
  25. 66 0
      v7/test/migrate/_v6.js
  26. 125 0
      v7/test/migrate/read-v6.test.js
  27. 127 0
      v7/test/migrate/transform.test.js

+ 3 - 0
.gitignore

@@ -15,6 +15,9 @@ venv/
 
 # Local runtime data
 .webnovel/
+# v6 迁移测试 fixture 例外(目录先重包含,内容才可重包含)
+!v7/test/fixtures/*/.webnovel/
+!v7/test/fixtures/*/.webnovel/**
 
 # OS / editor
 .DS_Store

+ 333 - 0
v7/src/migrate/read-v6.js

@@ -0,0 +1,333 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
+import { parseFrontMatter } from '../storage/parsers/front-matter.js'
+
+/**
+ * v6 书项目归一读取层(migrate 的输入面,全程零写入)。
+ * v6 语义依据:任务 research/v6-data-inventory.md(file:line 证据),
+ * 兼容口径 = 其 Q13 十条(三种正文命名 / state.json 三形态 / 键名与别名漂移 / 缺表容忍)。
+ * @param {string} v6Path v6 项目根
+ * @returns {Promise<{ok: boolean, facts: object|null, error: string}>}
+ */
+export async function readV6Project(v6Path) {
+  const warnings = []
+  const hasBody = await exists(path.join(v6Path, '正文'))
+  const hasWebnovel = await exists(path.join(v6Path, '.webnovel'))
+  if (!hasBody && !hasWebnovel) {
+    return { ok: false, facts: null, error: `「${v6Path}」不像 v6 书项目:既没有 正文/ 也没有 .webnovel/。请指向 v6 书项目根目录。` }
+  }
+
+  // —— state.json(三形态:全量内联 / 5.4 精简 / 空损坏)——
+  let state = {}
+  const statePath = path.join(v6Path, '.webnovel', 'state.json')
+  if (await exists(statePath)) {
+    try {
+      state = JSON.parse(await fs.readFile(statePath, 'utf8')) || {}
+    } catch (err) {
+      warnings.push(`读取 .webnovel/state.json 失败(${err.message}):运行态数据不可用,仅迁移文件面内容。`)
+      state = {}
+    }
+  } else {
+    warnings.push('未找到 .webnovel/state.json:按纯文件面迁移(正文/大纲/设定集)。')
+  }
+
+  // —— index.db(只读;缺表/缺列容忍,Q13-10)——
+  const dbPath = path.join(v6Path, '.webnovel', 'index.db')
+  const db = (await exists(dbPath)) ? openReadOnly(dbPath, warnings) : null
+
+  const hasInlineEntities = state.entities_v3 && Object.keys(state.entities_v3).length > 0
+  const form = db && hasInlineEntities ? 'mixed' : db ? 'sqlite' : 'inline'
+
+  const facts = {
+    form,
+    project: state.project_info || state.project || {},
+    progress: state.progress || {},
+    protagonistState: state.protagonist_state || {},
+    strandTracker: state.strand_tracker || {},
+    worldSettings: state.world_settings || {},
+    chapters: await scanChapters(v6Path, warnings),
+    entities: [],
+    stateChanges: [],
+    relationships: [],
+    foreshadowing: (state.plot_threads?.foreshadowing || []).map(normalizeForeshadowing),
+    activeThreads: state.plot_threads?.active_threads || [],
+    chapterMeta: new Map(
+      Object.entries(state.chapter_meta || {}).map(([k, v]) => [Number(k), v])
+    ),
+    readingPower: new Map(),
+    summaries: await readSummaries(v6Path, warnings),
+    dbSummaries: new Map(),
+    outlines: await readOutlines(v6Path),
+    settingFiles: await readSettingFiles(v6Path),
+    scratchpad: await readJson(path.join(v6Path, '.webnovel', 'memory_scratchpad.json'), warnings, {}),
+    patterns: (await readJson(path.join(v6Path, '.webnovel', 'project_memory.json'), warnings, {})).patterns || [],
+    chaseDebtCount: 0,
+    warnings,
+  }
+
+  // —— 实体:db 为准,state 内联补缺(Q13-2/3/7)——
+  const byId = new Map()
+  if (db) {
+    const aliasRows = tryAll(db, 'SELECT alias, entity_id FROM aliases') || []
+    const aliasMap = new Map()
+    for (const r of aliasRows) {
+      if (!aliasMap.has(r.entity_id)) aliasMap.set(r.entity_id, [])
+      aliasMap.get(r.entity_id).push(r.alias)
+    }
+    const rows =
+      tryAll(db, 'SELECT * FROM entities WHERE is_archived = 0') ??
+      tryAll(db, 'SELECT * FROM entities') ??
+      []
+    for (const r of rows) {
+      byId.set(r.id, {
+        id: r.id,
+        type: r.type || '角色',
+        name: r.canonical_name || r.id,
+        aliases: aliasMap.get(r.id) || [],
+        tier: r.tier || '装饰',
+        isProtagonist: !!r.is_protagonist,
+        current: parseJsonOr(r.current_json, {}),
+        desc: r.desc || '',
+        firstAppearance: r.first_appearance ?? null,
+        lastAppearance: r.last_appearance ?? null,
+      })
+    }
+    facts.stateChanges = (tryAll(db, 'SELECT * FROM state_changes ORDER BY chapter, id') || []).map((r) => ({
+      entityId: r.entity_id, field: r.field, old: r.old_value, new: r.new_value,
+      reason: r.reason || '', chapter: r.chapter ?? null,
+    }))
+    facts.relationships = (tryAll(db, 'SELECT * FROM relationships ORDER BY chapter, id') || []).map((r) => ({
+      from: r.from_entity, to: r.to_entity, type: r.type || '', description: r.description || '', chapter: r.chapter ?? null,
+    }))
+    for (const r of tryAll(db, 'SELECT chapter, title, summary FROM chapters') || []) {
+      if (r.summary) facts.dbSummaries.set(r.chapter, r.summary)
+    }
+    for (const r of tryAll(db, 'SELECT * FROM chapter_reading_power') || []) {
+      facts.readingPower.set(r.chapter, {
+        hookType: r.hook_type || '', hookStrength: r.hook_strength || 'medium',
+        coolpointPatterns: parseJsonOr(r.coolpoint_patterns, []),
+      })
+    }
+    facts.chaseDebtCount = (tryAll(db, 'SELECT COUNT(*) AS n FROM chase_debt') || [{ n: 0 }])[0].n
+    db.close()
+  }
+  if (hasInlineEntities) {
+    const aliasIndex = state.alias_index || {}
+    for (const [type, group] of Object.entries(state.entities_v3)) {
+      for (const [id, e] of Object.entries(group || {})) {
+        if (byId.has(id)) continue // db 为准
+        const aliases = new Set(e.aliases || [])
+        for (const [alias, refs] of Object.entries(aliasIndex)) {
+          if ((refs || []).some((ref) => ref.id === id)) aliases.add(alias)
+        }
+        byId.set(id, {
+          id, type,
+          name: e.canonical_name || e.name || id,
+          aliases: [...aliases],
+          tier: e.tier || '装饰',
+          isProtagonist: !!e.is_protagonist,
+          current: e.current || {},
+          desc: e.desc || '',
+          firstAppearance: e.first_appearance ?? null,
+          lastAppearance: e.last_appearance ?? null,
+        })
+      }
+    }
+    facts.stateChanges.push(...(state.state_changes || []).map((c) => ({
+      entityId: c.entity_id, field: c.field,
+      old: c.old ?? c.old_value ?? '', new: c.new ?? c.new_value ?? '',
+      reason: c.reason || '', chapter: c.chapter ?? null,
+    })))
+    facts.relationships.push(...(state.structured_relationships || []).map((r) => ({
+      from: r.from ?? r.from_entity, to: r.to ?? r.to_entity,
+      type: r.type || '', description: r.description || '', chapter: r.chapter ?? null,
+    })))
+  }
+  facts.entities = [...byId.values()]
+
+  return { ok: true, facts, error: '' }
+}
+
+// —— 伏笔规范化(v6 state_validator 口径:status/tier 别名、埋设章多键名,research Q4)——
+const RESOLVED_STATUS = new Set(['已回收', '已解决', 'resolved', 'done', 'closed'])
+const TIER_MAP = new Map([
+  ['核心', '核心'], ['core', '核心'], ['主线', '核心'],
+  ['支线', '支线'], ['support', '支线'],
+  ['装饰', '装饰'], ['decoration', '装饰'],
+])
+
+function normalizeForeshadowing(raw) {
+  const status = RESOLVED_STATUS.has(String(raw.status || '').toLowerCase()) ? '已回收' : '未回收'
+  return {
+    content: raw.content || '',
+    status,
+    tier: TIER_MAP.get(String(raw.tier || '').toLowerCase()) || '支线',
+    plantedChapter: firstNum(raw.planted_chapter, raw.added_chapter, raw.source_chapter, raw.start_chapter, raw.chapter),
+    targetChapter: firstNum(raw.target_chapter, raw.due_chapter, raw.deadline_chapter, raw.resolve_by_chapter, raw.target),
+    resolvedChapter: firstNum(raw.resolved_chapter, raw.resolved_at_chapter, raw.resolved),
+    urgency: raw.urgency ?? null,
+  }
+}
+
+// —— 正文扫描:平坦 4 位带标题 / 遗留纯章号 / 卷内 3 位(Q13-1)——
+async function scanChapters(v6Path, warnings) {
+  const root = path.join(v6Path, '正文')
+  const found = new Map()
+  let entries
+  try {
+    entries = await fs.readdir(root, { withFileTypes: true })
+  } catch {
+    warnings.push('未找到 正文/ 目录:没有可迁移的章节。')
+    return []
+  }
+  const collect = async (dir, volumeHint) => {
+    for (const ent of await fs.readdir(dir, { withFileTypes: true })) {
+      if (!ent.isFile()) continue
+      const m = ent.name.match(/^第(\d+)章(?:-(.+?))?\.md$/)
+      if (!m) continue
+      const num = Number(m[1])
+      if (found.has(num)) {
+        warnings.push(`第 ${num} 章有多个文件(${found.get(num).sourceName} 与 ${ent.name}),取前者,后者未迁移。`)
+        continue
+      }
+      found.set(num, {
+        num,
+        title: m[2] || null,
+        body: await fs.readFile(path.join(dir, ent.name), 'utf8'),
+        volumeHint,
+        sourceName: ent.name,
+      })
+    }
+  }
+  await collect(root, null)
+  for (const ent of entries) {
+    if (!ent.isDirectory()) continue
+    const vm = ent.name.match(/^第(\d+)卷$/)
+    if (vm) await collect(path.join(root, ent.name), Number(vm[1]))
+  }
+  return [...found.values()].sort((a, b) => a.num - b.num)
+}
+
+async function readSummaries(v6Path, warnings) {
+  const dir = path.join(v6Path, '.webnovel', 'summaries')
+  const map = new Map()
+  let files
+  try {
+    files = await fs.readdir(dir)
+  } catch {
+    return map
+  }
+  for (const f of files) {
+    const m = f.match(/^ch(\d+)\.md$/)
+    if (!m) continue
+    const parsed = parseFrontMatter(await fs.readFile(path.join(dir, f), 'utf8'))
+    if (parsed.ok) {
+      map.set(Number(m[1]), { frontMatter: parsed.data, body: parsed.body })
+    } else {
+      warnings.push(`章摘要 ${f} 解析失败,该章摘要未迁移:${parsed.error}`)
+    }
+  }
+  return map
+}
+
+async function readOutlines(v6Path) {
+  const dir = path.join(v6Path, '大纲')
+  const outlines = { master: '', volumes: [] }
+  let files
+  try {
+    files = await fs.readdir(dir)
+  } catch {
+    return outlines
+  }
+  const byVol = new Map()
+  const vol = (n) => {
+    if (!byVol.has(n)) byVol.set(n, { n, 详细大纲: '', 时间线: '', 拆分章纲: [] })
+    return byVol.get(n)
+  }
+  for (const f of files) {
+    const full = path.join(dir, f)
+    let m
+    if (f === '总纲.md') {
+      outlines.master = await fs.readFile(full, 'utf8')
+    } else if ((m = f.match(/^第(\d+)卷-详细大纲\.md$/))) {
+      vol(Number(m[1])).详细大纲 = await fs.readFile(full, 'utf8')
+    } else if ((m = f.match(/^第(\d+)卷-时间线\.md$/))) {
+      vol(Number(m[1])).时间线 = await fs.readFile(full, 'utf8')
+    } else if (/^第\d+章[-—_ ].+\.md$/.test(f)) {
+      vol(1).拆分章纲.push({ name: f, content: await fs.readFile(full, 'utf8') })
+    }
+  }
+  outlines.volumes = [...byVol.values()].sort((a, b) => a.n - b.n)
+  return outlines
+}
+
+async function readSettingFiles(v6Path) {
+  const dir = path.join(v6Path, '设定集')
+  const out = []
+  try {
+    for (const f of await fs.readdir(dir)) {
+      if (f.endsWith('.md')) out.push({ name: f, content: await fs.readFile(path.join(dir, f), 'utf8') })
+    }
+  } catch {
+    // 无设定集
+  }
+  return out
+}
+
+// —— 小工具 ——
+async function exists(p) {
+  try {
+    await fs.access(p)
+    return true
+  } catch {
+    return false
+  }
+}
+
+async function readJson(p, warnings, fallback) {
+  try {
+    return JSON.parse(await fs.readFile(p, 'utf8')) || fallback
+  } catch (err) {
+    if (err.code !== 'ENOENT') warnings.push(`读取 ${path.basename(p)} 失败(${err.message}),该部分未迁移。`)
+    return fallback
+  }
+}
+
+function openReadOnly(dbPath, warnings) {
+  try {
+    try {
+      return new DatabaseSync(dbPath, { readOnly: true })
+    } catch {
+      return new DatabaseSync(dbPath)
+    }
+  } catch (err) {
+    warnings.push(`打开 .webnovel/index.db 失败(${err.message}):数据库内容未迁移。`)
+    return null
+  }
+}
+
+function tryAll(db, sql) {
+  try {
+    return db.prepare(sql).all()
+  } catch {
+    return null // 表/列缺失属正常增量形态
+  }
+}
+
+function parseJsonOr(text, fallback) {
+  if (!text) return fallback
+  try {
+    return JSON.parse(text)
+  } catch {
+    return fallback
+  }
+}
+
+function firstNum(...vals) {
+  for (const v of vals) {
+    const n = Number(v)
+    if (v !== undefined && v !== null && v !== '' && Number.isFinite(n)) return n
+  }
+  return null
+}

+ 257 - 0
v7/src/migrate/transform.js

@@ -0,0 +1,257 @@
+import { serializeFrontMatter } from '../storage/serializers/front-matter.js'
+import { serializeMarkdownTable } from '../storage/serializers/markdown-table.js'
+import { parseMarkdownTable } from '../storage/parsers/markdown-table.js'
+import { extractSection } from '../util/markdown.js'
+import { sanitizeFileName } from '../util/filename.js'
+
+const GENRE_MAP = new Map([
+  ['xianxia', '仙侠'], ['xiuxian', '仙侠'],
+  ['xuanhuan', '玄幻'], ['fantasy', '玄幻'],
+  ['urban', '都市'], ['dushi', '都市'],
+  ['scifi', '科幻'], ['kehuan', '科幻'],
+  ['history', '历史'], ['lishi', '历史'],
+])
+const STRENGTH_MAP = new Map([
+  ['strong', '强'], ['medium', '中'], ['weak', '弱'],
+  ['强', '强'], ['中', '中'], ['弱', '弱'],
+])
+const TIER_TO_STRENGTH = new Map([['核心', '高'], ['支线', '中'], ['装饰', '低']])
+
+/**
+ * V6Facts → v7 书仓库文件计划(纯函数零 IO,design §3.2 映射表逐行)。
+ * @returns {{files: {path: string, content: string}[], report: object, bookName: string}}
+ */
+export function transformV6(facts) {
+  const files = []
+  const 待校对 = []
+  const 丢弃 = []
+  const counts = {}
+  const add = (p, content) => files.push({ path: p, content })
+
+  // —— book.yaml ——
+  const bookName = facts.project.title || '未命名'
+  const rawGenre = facts.project.genre || ''
+  let 类型 = GENRE_MAP.get(String(rawGenre).toLowerCase()) || rawGenre
+  if (!类型) {
+    类型 = '玄幻'
+    待校对.push('book.yaml:v6 未记录题材,暂填「玄幻」,请改成实际题材。')
+  } else if (!GENRE_MAP.get(String(rawGenre).toLowerCase()) && !/^[一-鿿]+$/.test(类型)) {
+    待校对.push(`book.yaml:题材「${rawGenre}」没有对应的中文码表,原样保留,请改成中文题材名。`)
+  }
+  add('book.yaml', [
+    'spec_version: "7.0"',
+    `书名: ${bookName}`,
+    `类型: ${类型}`,
+    '每章目标字数: 3000',
+    '卷规模: 40',
+    '',
+  ].join('\n'))
+
+  // —— 卷号推断:卷内布局优先,详细大纲「### 第N章」次之,兜底 1 ——
+  const volumeOf = buildVolumeIndex(facts.outlines)
+
+  // —— 正文章 ——
+  for (const ch of facts.chapters) {
+    const title = ch.title || `第${ch.num}章`
+    const fm = {
+      章号: ch.num,
+      标题: title,
+      卷: ch.volumeHint ?? volumeOf.get(ch.num) ?? 1,
+      字数: ch.body.replace(/\s/g, '').length,
+      章定位: '迁移',
+    }
+    const hook = hookOf(facts, ch.num)
+    if (hook) fm.钩子 = hook
+    fm.本章要写到的事 = ['迁移']
+    add(`定稿/正文/${pad4(ch.num)}-${sanitizeFileName(title)}.md`, serializeFrontMatter(fm, ch.body.trim() + '\n'))
+  }
+  counts.章数 = facts.chapters.length
+
+  // —— 章摘要:summaries 文件 > db summary 列(权衡 4.2)——
+  let 摘要数 = 0
+  for (const ch of facts.chapters) {
+    const fromFile = facts.summaries.get(ch.num)
+    const text = fromFile
+      ? (extractSection(fromFile.body, '剧情摘要') || fromFile.body).trim()
+      : (facts.dbSummaries.get(ch.num) || '').trim()
+    if (text) {
+      add(`定稿/摘要/章摘要/${pad4(ch.num)}.md`, text + '\n')
+      摘要数++
+    }
+  }
+  counts.摘要 = 摘要数
+
+  // —— 伏笔条目 ——
+  facts.foreshadowing.forEach((fb, i) => {
+    const id = `伏笔-${String(i + 1).padStart(3, '0')}`
+    const 短题 = sanitizeFileName(fb.content).slice(0, 9) || '迁移条目'
+    const planted = fb.plantedChapter ?? 1
+    const fm = {
+      强度: TIER_TO_STRENGTH.get(fb.tier) || '中',
+      状态: fb.status === '已回收' ? '已收尾' : '进行',
+      开启章: planted,
+    }
+    if (fb.targetChapter) fm.预计收尾 = `第${fb.targetChapter}章`
+    fm.最后推进章 = fb.resolvedChapter ?? planted
+    const 收尾计划 = fb.targetChapter
+      ? `第${fb.targetChapter}章前后回收(迁移自 v6 目标章)。`
+      : '迁移待补(校对时补写,防悬空)。'
+    if (!fb.targetChapter && fb.status !== '已回收') {
+      待校对.push(`${id}(${短题}):v6 没有目标回收章,收尾计划标了「迁移待补」。`)
+    }
+    const 履历 = [`- 第${planted}章:埋下(迁移)`]
+    if (fb.resolvedChapter) 履历.push(`- 第${fb.resolvedChapter}章:回收(迁移)`)
+    add(`大纲/伏笔/${id}-${短题}.md`, serializeFrontMatter(
+      fm,
+      `## 描述\n${fb.content}\n\n## 收尾计划\n${收尾计划}\n\n## 履历\n${履历.join('\n')}\n`
+    ))
+  })
+  counts.伏笔 = facts.foreshadowing.length
+
+  // —— 名册 + 角色卡 ——
+  const nameOf = new Map(facts.entities.map((e) => [e.id, e.name]))
+  if (facts.entities.length) {
+    add('定稿/设定/名册.md', serializeMarkdownTable(
+      ['正名', '别名', '类型', '首现章'],
+      facts.entities.map((e) => ({
+        正名: e.name, 别名: e.aliases.join(', '), 类型: e.type, 首现章: e.firstAppearance ?? '',
+      }))
+    ))
+  }
+  let 角色卡 = 0
+  for (const e of facts.entities) {
+    if (e.type !== '角色') continue
+    const fm = { 姓名: e.name }
+    if (e.aliases.length) fm.别名 = e.aliases
+    if (e.current.状态) fm.状态 = e.current.状态
+    if (e.current.location || e.current.位置) fm.位置 = e.current.location || e.current.位置
+    if (e.current.realm || e.current.境界) fm.境界 = e.current.realm || e.current.境界
+    if (e.lastAppearance) fm.最后变更章 = e.lastAppearance
+    const 设定行 = []
+    if (e.desc) 设定行.push(e.desc)
+    for (const [k, v] of Object.entries(e.current)) {
+      if (!['状态', 'location', '位置', 'realm', '境界'].includes(k)) 设定行.push(`${k}:${v}`)
+    }
+    if (e.isProtagonist && facts.protagonistState.golden_finger?.name) {
+      const gf = facts.protagonistState.golden_finger
+      设定行.push(`金手指:${gf.name}${gf.level ? `(等级 ${gf.level})` : ''}`)
+    }
+    const 关系行 = facts.relationships
+      .filter((r) => r.from === e.id || r.to === e.id)
+      .map((r) => `- 与${nameOf.get(r.from === e.id ? r.to : r.from) || '未知'}:${r.type}${r.description ? `——${r.description}` : ''}${r.chapter ? `(第${r.chapter}章)` : ''}`)
+    add(`定稿/设定/角色/${sanitizeFileName(e.name)}.md`, serializeFrontMatter(
+      fm,
+      `## 设定\n${设定行.join('\n') || '(迁移未采集)'}\n\n## 典型对话\n(迁移未采集)\n\n## 关系\n${关系行.join('\n') || '(迁移未采集)'}\n`
+    ))
+    角色卡++
+  }
+  counts.角色卡 = 角色卡
+
+  // —— 时间线(v6 表 章节|时间|事件 → v7 表)——
+  let 时间线行 = 0
+  for (const vol of facts.outlines.volumes) {
+    if (!vol.时间线) continue
+    const parsed = parseMarkdownTable(tableSlice(vol.时间线))
+    if (!parsed.ok || !parsed.rows.length) continue
+    const rows = parsed.rows.map((r) => ({
+      章: r['章节'] ?? r['章'] ?? '',
+      书内时间: r['时间'] ?? r['书内时间'] ?? '',
+      一句话事件: r['事件'] ?? r['一句话事件'] ?? '',
+      在场: r['在场'] ?? '',
+    }))
+    add(`定稿/设定/时间线/第${pad2(vol.n)}卷.md`, serializeMarkdownTable(['章', '书内时间', '一句话事件', '在场'], rows))
+    时间线行 += rows.length
+  }
+  counts.时间线行 = 时间线行
+
+  // —— 大纲:总纲原样、详细大纲→卷纲(active_threads/拆分章纲 并入)——
+  if (facts.outlines.master) add('大纲/总纲.md', facts.outlines.master)
+  const lastVol = facts.outlines.volumes[facts.outlines.volumes.length - 1]
+  for (const vol of facts.outlines.volumes) {
+    let content = vol.详细大纲 || `# 第${vol.n}卷 卷纲(迁移占位)\n`
+    if (vol.拆分章纲.length) {
+      content += `\n## 迁移的拆分章纲\n` + vol.拆分章纲.map((s) => `### ${s.name}\n${s.content.trim()}`).join('\n\n') + '\n'
+    }
+    if (facts.activeThreads.length && vol === lastVol) {
+      content += `\n## 迁移的剧情线\n` + facts.activeThreads
+        .map((t) => `- ${t.name || '未命名'}:${t.note || t.description || ''}${t.status ? `(v6 状态 ${t.status})` : ''}`)
+        .join('\n') + '\n'
+    }
+    add(`大纲/卷纲/第${pad2(vol.n)}卷.md`, content)
+  }
+  counts.卷纲 = facts.outlines.volumes.length
+
+  // —— 设定集原样搬 ——
+  for (const f of facts.settingFiles) add(`定稿/设定/${f.name}`, f.content)
+  counts.设定文件 = facts.settingFiles.length
+
+  // —— 待校对三件(人工过一遍再入)——
+  const buckets = ['character_state', 'story_facts', 'world_rules', 'timeline', 'open_loops', 'reader_promises', 'relationships']
+  const memSections = buckets
+    .filter((b) => (facts.scratchpad[b] || []).length)
+    .map((b) => `## ${b}\n` + facts.scratchpad[b]
+      .map((it) => `- ${it.subject ? `【${it.subject}】` : ''}${it.value || ''}${it.source_chapter ? `(第${it.source_chapter}章)` : ''}${(it.evidence || []).length ? ` 证据:${it.evidence.join(';')}` : ''}`)
+      .join('\n'))
+  if (memSections.length) {
+    add('定稿/设定/迁移待校对-记忆清单.md',
+      `# 迁移待校对:v6 记忆清单\n\n> 人工过一遍:有用的登记成条目/写进设定,没用的删掉本文件。\n\n${memSections.join('\n\n')}\n`)
+    待校对.push('定稿/设定/迁移待校对-记忆清单.md:v6 记忆分桶清单(含 open_loops 开放线索,酌情登记成条目)。')
+  }
+  if (facts.patterns.length) {
+    add('文风/迁移待校对-文风候选.md',
+      `# 迁移待校对:v6 学到的文风套路\n\n> 人工过一遍再并入 文风/文风铁律.md,之后删掉本文件。\n\n` +
+      facts.patterns.map((p) => `- [${p.pattern_type || '未分类'}] ${p.description || ''}${p.source_chapter ? `(第${p.source_chapter}章)` : ''}`).join('\n') + '\n')
+    待校对.push('文风/迁移待校对-文风候选.md:v6 patterns,过目后并入文风铁律。')
+  }
+  if (facts.stateChanges.length) {
+    add('定稿/设定/迁移待校对-实体变更史.md',
+      `# 迁移待校对:v6 实体变更流水\n\n> 当前值已并入角色卡;这里是历史流水,校对后可删。\n\n` +
+      facts.stateChanges.map((c) => `- 第${c.chapter ?? '?'}章:${nameOf.get(c.entityId) || c.entityId} 的 ${c.field}「${c.old}」→「${c.new}」${c.reason ? `(${c.reason})` : ''}`).join('\n') + '\n')
+    待校对.push('定稿/设定/迁移待校对-实体变更史.md:变更流水存档。')
+  }
+
+  // —— 如实丢弃 ——
+  if (facts.chaseDebtCount > 0) {
+    丢弃.push(`追读力债务台账(chase_debt)${facts.chaseDebtCount} 条——v7 无债务体系,未迁移。`)
+  }
+  丢弃.push('派生库与缓存(index.db 实体已转正文件、vectors.db、rag.db、projection_log、context_cache、.story-system/、observability、backups)——v7 缓存会从新仓库重建。')
+  丢弃.push(...facts.warnings)
+
+  return {
+    files,
+    bookName,
+    report: { counts, 待校对, 丢弃, form: facts.form },
+  }
+}
+
+// —— 工具 ——
+function buildVolumeIndex(outlines) {
+  const map = new Map()
+  for (const vol of outlines.volumes) {
+    const re = /###\s*第(\d+)章/g
+    let m
+    while ((m = re.exec(vol.详细大纲 || ''))) map.set(Number(m[1]), vol.n)
+  }
+  return map
+}
+
+function hookOf(facts, num) {
+  const meta = facts.chapterMeta.get(num)?.hook
+  if (meta?.type) return `${meta.type}-${STRENGTH_MAP.get(String(meta.strength || '').toLowerCase()) || '中'}`
+  const rp = facts.readingPower.get(num)
+  if (rp?.hookType) return `${rp.hookType}-${STRENGTH_MAP.get(String(rp.hookStrength || '').toLowerCase()) || '中'}`
+  return null
+}
+
+/** 从自由 markdown 中截取表格行(v6 时间线文件表格前有标题行)。 */
+function tableSlice(content) {
+  return content.split('\n').filter((l) => l.trim().startsWith('|')).join('\n')
+}
+
+function pad4(n) {
+  return String(n).padStart(4, '0')
+}
+
+function pad2(n) {
+  return String(n).padStart(2, '0')
+}

+ 1 - 0
v7/test/fixtures/v6-inline/.story-system/MASTER_SETTING.json

@@ -0,0 +1 @@
+{"meta":{"schema_version":"story-system/v1","contract_type":"MASTER_SETTING"},"route":"xianxia"}

+ 16 - 0
v7/test/fixtures/v6-inline/.webnovel/memory_scratchpad.json

@@ -0,0 +1,16 @@
+{
+  "character_state": [
+    { "id": "cs-1", "layer": "semantic", "category": "character_state", "subject": "陆沉", "field": "灵根", "value": "伪装成废灵根", "status": "active", "source_chapter": 1, "evidence": ["第1章盘问一节"], "updated_at": "2026-05-01" }
+  ],
+  "story_facts": [
+    { "id": "sf-1", "layer": "semantic", "category": "story_facts", "subject": "残剑", "field": "来历", "value": "陆家灭门夜唯一遗物", "status": "active", "source_chapter": 1, "evidence": [], "updated_at": "2026-05-01" }
+  ],
+  "world_rules": [],
+  "timeline": [],
+  "open_loops": [
+    { "id": "ol-1", "layer": "episodic", "category": "open_loops", "subject": "盯梢", "field": "", "value": "三长老着人盯梢陆沉,尚未收线", "status": "active", "source_chapter": 1, "evidence": [], "updated_at": "2026-05-01" }
+  ],
+  "reader_promises": [],
+  "relationships": [],
+  "meta": { "version": 1, "last_updated": "2026-05-01", "total_items": 3 }
+}

+ 6 - 0
v7/test/fixtures/v6-inline/.webnovel/project_memory.json

@@ -0,0 +1,6 @@
+{
+  "patterns": [
+    { "pattern_type": "style", "description": "战斗段落短句连用,收在动作定格", "source_chapter": 3, "learned_at": "2026-05-01", "updated_at": "2026-05-01" },
+    { "pattern_type": "taboo", "description": "避免'顿时''瞬间'连用刷时间感", "source_chapter": 2, "learned_at": "2026-05-01", "updated_at": "2026-05-01" }
+  ]
+}

+ 1 - 0
v7/test/fixtures/v6-inline/.webnovel/projection_log.jsonl

@@ -0,0 +1 @@
+{"schema_version":1,"run_id":"r1","chapter":1,"status":"ok"}

+ 112 - 0
v7/test/fixtures/v6-inline/.webnovel/state.json

@@ -0,0 +1,112 @@
+{
+  "project": {
+    "title": "剑碎虚空",
+    "genre": "xianxia",
+    "author": "测试作者"
+  },
+  "progress": {
+    "current_chapter": 3,
+    "total_words": 9000,
+    "last_updated": "2026-05-01"
+  },
+  "protagonist_state": {
+    "name": "陆沉",
+    "power": { "realm": "练气", "layer": "五层", "bottleneck": "灵脉淤塞" },
+    "location": { "current": "青云宗外门", "last_chapter": 3 },
+    "golden_finger": { "name": "残剑剑灵", "level": 2, "skills": ["剑意共鸣"] },
+    "attributes": {}
+  },
+  "entities_v3": {
+    "角色": {
+      "luchen": {
+        "name": "陆沉",
+        "canonical_name": "陆沉",
+        "tier": "核心",
+        "aliases": ["小师弟"],
+        "is_protagonist": true,
+        "first_appearance": 1,
+        "last_appearance": 3,
+        "current": { "realm": "练气五层", "location": "青云宗外门" },
+        "desc": "背负残剑的外门弟子"
+      },
+      "susu": {
+        "name": "苏素",
+        "canonical_name": "苏素",
+        "tier": "支线",
+        "aliases": ["苏师姐"],
+        "first_appearance": 2,
+        "last_appearance": 3,
+        "current": { "realm": "筑基一层", "location": "藏经阁" }
+      }
+    },
+    "势力": {
+      "qingyun": {
+        "name": "青云宗",
+        "canonical_name": "青云宗",
+        "tier": "核心",
+        "aliases": ["宗门"],
+        "first_appearance": 1,
+        "last_appearance": 3,
+        "current": { "status": "东域二流宗门" }
+      }
+    }
+  },
+  "alias_index": {
+    "小师弟": [{ "id": "luchen", "type": "角色" }],
+    "苏师姐": [{ "id": "susu", "type": "角色" }],
+    "宗门": [{ "id": "qingyun", "type": "势力" }]
+  },
+  "state_changes": [
+    { "entity_id": "luchen", "field": "realm", "old": "练气四层", "new": "练气五层", "reason": "剑灵反哺", "chapter": 3 }
+  ],
+  "structured_relationships": [
+    { "from": "luchen", "to": "susu", "type": "师姐弟", "description": "藏经阁初见,苏素予其残卷", "chapter": 2 }
+  ],
+  "plot_threads": {
+    "active_threads": [
+      { "name": "外门大比", "status": "active", "note": "三个月后外门大比,陆沉需入前十" }
+    ],
+    "foreshadowing": [
+      {
+        "content": "残剑剑柄内藏半张古图",
+        "status": "active",
+        "tier": "core",
+        "planted_chapter": 1,
+        "target_chapter": 30,
+        "urgency": 80
+      },
+      {
+        "content": "苏素识破陆沉伪装的灵根",
+        "status": "已解决",
+        "tier": "支线",
+        "chapter": 2,
+        "resolved": 3
+      }
+    ]
+  },
+  "chapter_meta": {
+    "0001": {
+      "hook": { "type": "危机钩", "content": "长老逼问残剑来历", "strength": "strong" }
+    },
+    "3": {
+      "hook": { "type": "悬念钩", "content": "古图一角浮现", "strength": "medium" }
+    }
+  },
+  "strand_tracker": {
+    "last_quest_chapter": 3,
+    "current_dominant": "quest",
+    "history": [
+      { "chapter": 1, "strand": "quest" },
+      { "chapter": 2, "strand": "constellation" },
+      { "chapter": 3, "strand": "quest" }
+    ]
+  },
+  "world_settings": {
+    "power_system": ["练气", "筑基", "金丹"],
+    "factions": [{ "name": "青云宗", "type": "宗门" }],
+    "locations": ["青云宗", "藏经阁"]
+  },
+  "review_checkpoints": [],
+  "disambiguation_warnings": [],
+  "disambiguation_pending": []
+}

+ 18 - 0
v7/test/fixtures/v6-inline/.webnovel/summaries/ch0001.md

@@ -0,0 +1,18 @@
+---
+chapter: "0001"
+time: 春末清晨
+location: 青云宗演武场
+characters: [陆沉, 三长老]
+state_changes: []
+hook_type: 危机钩
+hook_strength: strong
+---
+
+## 剧情摘要
+陆沉携残剑入演武场受三长老盘问,隐瞒剑灵之事,仅以家传旧物搪塞过关。
+
+## 伏笔
+- [埋设] 残剑剑柄内藏半张古图
+
+## 承接点
+三长老似有疑虑,暗中着人盯梢。

+ 0 - 0
v7/test/fixtures/v6-inline/.webnovel/vectors.db


+ 13 - 0
v7/test/fixtures/v6-inline/大纲/总纲.md

@@ -0,0 +1,13 @@
+# 剑碎虚空 总纲
+
+## 基本信息
+- 类型:仙侠
+- 主角:陆沉
+- 金手指:残剑剑灵
+
+## 结局
+陆沉集齐古图,于虚空裂隙斩落伪天道。
+
+## 第1卷章纲
+### 第1章:残剑出鞘
+长老盘问,危机暂过。

+ 7 - 0
v7/test/fixtures/v6-inline/大纲/第1卷-时间线.md

@@ -0,0 +1,7 @@
+# 第1卷 时间线
+
+| 章节 | 时间 | 事件 |
+|------|------|------|
+| 1 | 春末清晨 | 演武场盘问,残剑现世 |
+| 2 | 三日后 | 藏经阁初遇苏素 |
+| 3 | 当夜子时 | 剑灵初醒,破至练气五层 |

+ 10 - 0
v7/test/fixtures/v6-inline/大纲/第1卷-详细大纲.md

@@ -0,0 +1,10 @@
+# 第1卷 外门风云 详细大纲
+
+### 第1章:残剑出鞘
+目标:立主角处境。阻力:三长老盘问。钩子:盘问未尽。
+
+### 第2章:藏经阁
+目标:引出苏素与残卷。钩子:残卷缺页。
+
+### 第3章:剑灵初醒
+目标:第一次突破。钩子:古图一角。

+ 5 - 0
v7/test/fixtures/v6-inline/正文/第0001章-残剑出鞘.md

@@ -0,0 +1,5 @@
+晨雾未散,陆沉背着那柄锈迹斑斑的残剑走进演武场。
+
+"外门弟子陆沉,见过三长老。"他垂首行礼,指节却在剑柄上收紧。
+
+三长老眯起眼:"这剑,你从何处得来?"

+ 5 - 0
v7/test/fixtures/v6-inline/正文/第0002章.md

@@ -0,0 +1,5 @@
+藏经阁的木梯吱呀作响。
+
+苏素抱着一摞残卷从阁上下来,目光落在陆沉腰间:"外门弟子,也配佩剑入阁?"
+
+陆沉没有抬头:"借阅《炼气初解》,登记在册。"

+ 5 - 0
v7/test/fixtures/v6-inline/正文/第1卷/第003章-剑灵初醒.md

@@ -0,0 +1,5 @@
+子夜,残剑忽然轻鸣。
+
+一缕剑意顺着陆沉的灵脉游走,淤塞多年的气海竟被撕开一道细缝。
+
+"练气五层。"他睁开眼,掌心古图一角若隐若现。

+ 7 - 0
v7/test/fixtures/v6-inline/设定集/世界观.md

@@ -0,0 +1,7 @@
+# 世界观
+
+## 力量体系
+练气 → 筑基 → 金丹 → 元婴。
+
+## 地点
+东域青云宗:外门三千弟子,藏经阁七层。

+ 5 - 0
v7/test/fixtures/v6-inline/设定集/主角卡.md

@@ -0,0 +1,5 @@
+# 主角卡:陆沉
+
+- 出身:陆家灭门遗孤
+- 性格:外怯内狠,恩怨分明
+- 底牌:残剑剑灵(不可示人)

+ 28 - 0
v7/test/fixtures/v6-sqlite/.webnovel/state.json

@@ -0,0 +1,28 @@
+{
+  "project_info": {
+    "title": "潮汐之下",
+    "genre": "urban",
+    "author": "测试作者二"
+  },
+  "progress": { "current_chapter": 2, "total_words": 5200, "last_updated": "2026-06-20" },
+  "protagonist_state": {
+    "name": "江遥",
+    "power": { "realm": "", "layer": "" },
+    "location": { "current": "滨海市", "last_chapter": 2 },
+    "golden_finger": { "name": "潮汐直觉", "level": 1 }
+  },
+  "strand_tracker": { "current_dominant": "quest", "history": [{ "chapter": 1, "strand": "quest" }] },
+  "world_settings": { "power_system": [], "factions": [], "locations": ["滨海市"] },
+  "plot_threads": {
+    "active_threads": [],
+    "foreshadowing": [
+      { "content": "父亲沉船事故的保险单编号异常", "status": "未回收", "tier": "核心", "planted_chapter": 1, "target_chapter": 40 }
+    ]
+  },
+  "relationships": {},
+  "review_checkpoints": [],
+  "disambiguation_warnings": [],
+  "disambiguation_pending": [],
+  "_migrated_to_sqlite": true,
+  "_migration_timestamp": "2026-06-01T00:00:00"
+}

+ 6 - 0
v7/test/fixtures/v6-sqlite/大纲/总纲.md

@@ -0,0 +1,6 @@
+# 潮汐之下 总纲
+
+都市悬疑:江遥追查父亲沉船真相。
+
+## 结局
+真相浮出,潮汐直觉的来历揭晓。

+ 7 - 0
v7/test/fixtures/v6-sqlite/大纲/第1卷-详细大纲.md

@@ -0,0 +1,7 @@
+# 第1卷 详细大纲
+
+### 第1章:退潮
+怀表现世。
+
+### 第2章:保险单
+编号疑点。

+ 3 - 0
v7/test/fixtures/v6-sqlite/正文/第0001章-退潮.md

@@ -0,0 +1,3 @@
+退潮后的滩涂上,江遥捡到了那只锈死的怀表。
+
+表盖内侧刻着一行小字:潮起时勿回头。

+ 3 - 0
v7/test/fixtures/v6-sqlite/正文/第0002章-保险单.md

@@ -0,0 +1,3 @@
+整理父亲遗物时,保险单的编号让江遥皱起了眉。
+
+编号末四位,正是怀表停摆的时刻。

+ 3 - 0
v7/test/fixtures/v6-sqlite/设定集/世界观.md

@@ -0,0 +1,3 @@
+# 世界观
+
+现代都市滨海市,潮汐异象频发。

+ 66 - 0
v7/test/migrate/_v6.js

@@ -0,0 +1,66 @@
+import path from 'node:path'
+import os from 'node:os'
+import { fileURLToPath } from 'node:url'
+import { mkdtemp, rm, cp } from 'node:fs/promises'
+import { DatabaseSync } from 'node:sqlite'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+
+export const inlineFixture = path.join(__dirname, '../fixtures/v6-inline')
+export const sqliteFixture = path.join(__dirname, '../fixtures/v6-sqlite')
+
+/** 拷 fixture 到临时目录(可写)。 */
+export async function tempV6(fixture) {
+  const tmp = await mkdtemp(path.join(os.tmpdir(), 'wnw-v6-'))
+  await cp(fixture, tmp, { recursive: true })
+  return { v6Path: tmp, cleanup: () => rm(tmp, { recursive: true, force: true }) }
+}
+
+/**
+ * 拷 v6-sqlite fixture 并现场建 index.db(DDL 摘自 v6 index_manager.py,
+ * 见任务 research/v6-data-inventory.md Q3;故意不建 chase_debt 等表——测缺表容忍)。
+ */
+export async function tempV6Sqlite() {
+  const { v6Path, cleanup } = await tempV6(sqliteFixture)
+  const db = new DatabaseSync(path.join(v6Path, '.webnovel', 'index.db'))
+  db.exec(`CREATE TABLE entities (
+    id TEXT PRIMARY KEY, type TEXT, canonical_name TEXT, tier TEXT DEFAULT '装饰',
+    desc TEXT, current_json TEXT, first_appearance INTEGER, last_appearance INTEGER,
+    is_protagonist INTEGER DEFAULT 0, is_archived INTEGER DEFAULT 0,
+    created_at TEXT, updated_at TEXT)`)
+  db.exec(`CREATE TABLE aliases (
+    alias TEXT, entity_id TEXT, entity_type TEXT, created_at TEXT,
+    PRIMARY KEY (alias, entity_id, entity_type))`)
+  db.exec(`CREATE TABLE state_changes (
+    id INTEGER PRIMARY KEY AUTOINCREMENT, entity_id TEXT, field TEXT,
+    old_value TEXT, new_value TEXT, reason TEXT, chapter INTEGER, created_at TEXT)`)
+  db.exec(`CREATE TABLE relationships (
+    id INTEGER PRIMARY KEY AUTOINCREMENT, from_entity TEXT, to_entity TEXT,
+    type TEXT, description TEXT, chapter INTEGER, created_at TEXT)`)
+  db.exec(`CREATE TABLE chapters (
+    chapter INTEGER PRIMARY KEY, title TEXT, location TEXT, word_count INTEGER,
+    characters TEXT, summary TEXT, created_at TEXT)`)
+  db.exec(`CREATE TABLE chapter_reading_power (
+    chapter INTEGER PRIMARY KEY, hook_type TEXT, hook_strength TEXT DEFAULT 'medium',
+    coolpoint_patterns TEXT, micropayoffs TEXT, hard_violations TEXT, soft_suggestions TEXT,
+    is_transition INTEGER DEFAULT 0, override_count INTEGER DEFAULT 0,
+    debt_balance REAL DEFAULT 0, created_at TEXT, updated_at TEXT)`)
+
+  db.prepare(`INSERT INTO entities (id, type, canonical_name, tier, desc, current_json, first_appearance, last_appearance, is_protagonist)
+    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
+    .run('jiangyao', '角色', '江遥', '核心', '滨海市海事记者', '{"location":"滨海市","状态":"在世"}', 1, 2, 1)
+  db.prepare(`INSERT INTO entities (id, type, canonical_name, tier, current_json, first_appearance, last_appearance)
+    VALUES (?, ?, ?, ?, ?, ?, ?)`)
+    .run('binhai', '地点', '滨海市', '支线', '{}', 1, 2)
+  db.prepare('INSERT INTO aliases (alias, entity_id, entity_type) VALUES (?, ?, ?)').run('小江', 'jiangyao', '角色')
+  db.prepare(`INSERT INTO state_changes (entity_id, field, old_value, new_value, reason, chapter)
+    VALUES (?, ?, ?, ?, ?, ?)`).run('jiangyao', 'location', '报社', '滨海市', '回乡奔丧', 1)
+  db.prepare(`INSERT INTO relationships (from_entity, to_entity, type, description, chapter)
+    VALUES (?, ?, ?, ?, ?)`).run('jiangyao', 'binhai', '故乡', '江遥的故乡', 1)
+  db.prepare('INSERT INTO chapters (chapter, title, summary) VALUES (?, ?, ?)')
+    .run(1, '退潮', '江遥在退潮滩涂拾得停摆怀表,表盖刻字暗藏警告。')
+  db.prepare('INSERT INTO chapter_reading_power (chapter, hook_type, hook_strength, coolpoint_patterns) VALUES (?, ?, ?, ?)')
+    .run(1, '悬念钩', 'strong', '["异物入手"]')
+  db.close()
+  return { v6Path, cleanup }
+}

+ 125 - 0
v7/test/migrate/read-v6.test.js

@@ -0,0 +1,125 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { readV6Project } from '../../src/migrate/read-v6.js'
+import { tempV6, tempV6Sqlite, inlineFixture } from './_v6.js'
+
+test('inline 形态:state 全量内联读取,键名/别名/三种正文命名全归一', async () => {
+  const { v6Path, cleanup } = await tempV6(inlineFixture)
+  try {
+    const r = await readV6Project(v6Path)
+    assert.equal(r.ok, true, r.error)
+    const f = r.facts
+    assert.equal(f.form, 'inline')
+    assert.equal(f.project.title, '剑碎虚空') // project 键(非 project_info)
+    assert.equal(f.project.genre, 'xianxia')
+
+    // 三种正文命名归一:平坦带标题 / 遗留无标题 / 卷内 3 位
+    assert.deepEqual(
+      f.chapters.map((c) => [c.num, c.title, c.volumeHint]),
+      [[1, '残剑出鞘', null], [2, null, null], [3, '剑灵初醒', 1]]
+    )
+    assert.match(f.chapters[0].body, /^晨雾未散/)
+
+    // 实体 + alias_index 反查
+    assert.equal(f.entities.length, 3)
+    const luchen = f.entities.find((e) => e.id === 'luchen')
+    assert.equal(luchen.name, '陆沉')
+    assert.equal(luchen.isProtagonist, true)
+    assert.ok(luchen.aliases.includes('小师弟'))
+
+    // 伏笔规范化:status/tier 别名、planted 多键名
+    assert.equal(f.foreshadowing.length, 2)
+    assert.deepEqual(
+      f.foreshadowing.map((x) => [x.status, x.tier, x.plantedChapter, x.resolvedChapter]),
+      [['未回收', '核心', 1, null], ['已回收', '支线', 2, 3]]
+    )
+
+    // chapter_meta 键 "0001"/"3" 归一为数字
+    assert.equal(f.chapterMeta.get(1).hook.type, '危机钩')
+    assert.equal(f.chapterMeta.get(3).hook.strength, 'medium')
+
+    assert.equal(f.summaries.get(1).frontMatter.hook_type, '危机钩')
+    assert.equal(f.scratchpad.open_loops.length, 1)
+    assert.equal(f.patterns.length, 2)
+    assert.equal(f.outlines.volumes.length, 1)
+    assert.match(f.outlines.volumes[0].详细大纲, /第3章:剑灵初醒/)
+    assert.match(f.outlines.volumes[0].时间线, /演武场盘问/)
+    assert.equal(f.settingFiles.length, 2)
+    assert.equal(f.activeThreads.length, 1)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('sqlite 形态:精简 state + index.db 分置,实体/摘要/追读力从 db 读', async () => {
+  const { v6Path, cleanup } = await tempV6Sqlite()
+  try {
+    const r = await readV6Project(v6Path)
+    assert.equal(r.ok, true, r.error)
+    const f = r.facts
+    assert.equal(f.form, 'sqlite')
+    assert.equal(f.project.title, '潮汐之下') // project_info 键
+
+    const jy = f.entities.find((e) => e.id === 'jiangyao')
+    assert.equal(jy.name, '江遥')
+    assert.deepEqual(jy.aliases, ['小江'])
+    assert.equal(jy.current.location, '滨海市') // current_json 解析
+
+    assert.equal(f.dbSummaries.get(1), '江遥在退潮滩涂拾得停摆怀表,表盖刻字暗藏警告。')
+    assert.equal(f.readingPower.get(1).hookType, '悬念钩')
+    assert.deepEqual(f.readingPower.get(1).coolpointPatterns, ['异物入手'])
+    assert.equal(f.stateChanges.length, 1)
+    assert.equal(f.relationships.length, 1)
+    // 缺 chase_debt 等表不炸(fixture db 故意没建)
+    assert.equal(f.foreshadowing.length, 1) // 伏笔仍在精简 state.json
+  } finally {
+    await cleanup()
+  }
+})
+
+test('容错:state.json 损坏 → 文件面照迁 + 如实 warning;源零写入', async () => {
+  const { v6Path, cleanup } = await tempV6(inlineFixture)
+  try {
+    await fs.writeFile(path.join(v6Path, '.webnovel', 'state.json'), '{broken', 'utf8')
+    const before = await fs.readFile(path.join(v6Path, '.webnovel', 'state.json'), 'utf8')
+
+    const r = await readV6Project(v6Path)
+    assert.equal(r.ok, true, r.error)
+    assert.equal(r.facts.chapters.length, 3) // 正文照读
+    assert.equal(r.facts.entities.length, 0)
+    assert.ok(r.facts.warnings.some((w) => w.includes('state.json')))
+
+    assert.equal(await fs.readFile(path.join(v6Path, '.webnovel', 'state.json'), 'utf8'), before)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('容错:整个 .webnovel/ 缺失 → 纯文件面迁移', async () => {
+  const { v6Path, cleanup } = await tempV6(inlineFixture)
+  try {
+    await fs.rm(path.join(v6Path, '.webnovel'), { recursive: true, force: true })
+    const r = await readV6Project(v6Path)
+    assert.equal(r.ok, true, r.error)
+    assert.equal(r.facts.chapters.length, 3)
+    assert.equal(r.facts.foreshadowing.length, 0)
+    assert.ok(r.facts.warnings.length > 0)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('不是 v6 项目(无 正文/ 无 .webnovel)→ 人话拒绝', async () => {
+  const { v6Path, cleanup } = await tempV6(inlineFixture)
+  try {
+    await fs.rm(path.join(v6Path, '.webnovel'), { recursive: true, force: true })
+    await fs.rm(path.join(v6Path, '正文'), { recursive: true, force: true })
+    const r = await readV6Project(v6Path)
+    assert.equal(r.ok, false)
+    assert.match(r.error, /不像 v6 书项目/)
+  } finally {
+    await cleanup()
+  }
+})

+ 127 - 0
v7/test/migrate/transform.test.js

@@ -0,0 +1,127 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { readV6Project } from '../../src/migrate/read-v6.js'
+import { transformV6 } from '../../src/migrate/transform.js'
+import { parseFrontMatter } from '../../src/storage/parsers/front-matter.js'
+import { tempV6, tempV6Sqlite, inlineFixture } from './_v6.js'
+
+function fileOf(plan, p) {
+  const f = plan.files.find((x) => x.path === p)
+  assert.ok(f, `缺文件 ${p};实有:\n${plan.files.map((x) => x.path).join('\n')}`)
+  return f.content
+}
+
+async function inlinePlan() {
+  const { v6Path, cleanup } = await tempV6(inlineFixture)
+  try {
+    const r = await readV6Project(v6Path)
+    assert.equal(r.ok, true, r.error)
+    return transformV6(r.facts)
+  } finally {
+    await cleanup()
+  }
+}
+
+test('inline → 正文补 front matter:迁移标记、钩子映射、卷号推断、标题兜底', async () => {
+  const plan = await inlinePlan()
+
+  const ch1 = parseFrontMatter(fileOf(plan, '定稿/正文/0001-残剑出鞘.md'))
+  assert.equal(ch1.ok, true, ch1.error)
+  assert.equal(ch1.data.章号, 1)
+  assert.equal(ch1.data.卷, 1)
+  assert.equal(ch1.data.章定位, '迁移')
+  assert.equal(ch1.data.钩子, '危机钩-强') // chapter_meta strong→强
+  assert.deepEqual(ch1.data.本章要写到的事, ['迁移'])
+  assert.ok(ch1.data.字数 > 0)
+  assert.equal(ch1.data.书内时间, undefined) // 无源省略,不编造
+  assert.match(ch1.body, /晨雾未散/)
+
+  const ch2 = parseFrontMatter(fileOf(plan, '定稿/正文/0002-第2章.md'))
+  assert.equal(ch2.data.标题, '第2章') // 遗留无标题兜底
+  assert.equal(ch2.data.钩子, undefined) // 无 hook 数据省略
+
+  const ch3 = parseFrontMatter(fileOf(plan, '定稿/正文/0003-剑灵初醒.md'))
+  assert.equal(ch3.data.钩子, '悬念钩-中')
+})
+
+test('inline → 伏笔条目/名册/角色卡/时间线/摘要/卷纲', async () => {
+  const plan = await inlinePlan()
+
+  const fb1 = parseFrontMatter(fileOf(plan, '大纲/伏笔/伏笔-001-残剑剑柄内藏半张古.md'))
+  assert.equal(fb1.data.状态, '进行')
+  assert.equal(fb1.data.强度, '高') // core→核心→高
+  assert.equal(fb1.data.开启章, 1)
+  assert.equal(fb1.data.预计收尾, '第30章')
+  assert.match(fb1.body, /## 描述\n残剑剑柄内藏半张古图/)
+  assert.match(fb1.body, /## 履历\n- 第1章:埋下(迁移)/)
+
+  const fb2 = parseFrontMatter(fileOf(plan, '大纲/伏笔/伏笔-002-苏素识破陆沉伪装的.md'))
+  assert.equal(fb2.data.状态, '已收尾')
+  assert.equal(fb2.data.最后推进章, 3)
+
+  const roster = fileOf(plan, '定稿/设定/名册.md')
+  assert.match(roster, /\| 陆沉 \| 小师弟 \| 角色 \| 1 \|/)
+  assert.match(roster, /\| 青云宗 \| 宗门 \| 势力 \| 1 \|/)
+
+  const card = fileOf(plan, '定稿/设定/角色/陆沉.md')
+  const cardFm = parseFrontMatter(card)
+  assert.equal(cardFm.data.姓名, '陆沉')
+  assert.match(card, /## 设定\n/)
+  assert.match(card, /背负残剑的外门弟子/)
+  assert.match(card, /## 关系\n[\s\S]*师姐弟/)
+
+  const tl = fileOf(plan, '定稿/设定/时间线/第01卷.md')
+  assert.match(tl, /\| 章 \| 书内时间 \| 一句话事件 \| 在场 \|/)
+  assert.match(tl, /\| 1 \| 春末清晨 \| 演武场盘问,残剑现世 \|/)
+
+  assert.match(fileOf(plan, '定稿/摘要/章摘要/0001.md'), /^陆沉携残剑入演武场/)
+
+  const vol = fileOf(plan, '大纲/卷纲/第01卷.md')
+  assert.match(vol, /第3章:剑灵初醒/)
+  assert.match(vol, /## 迁移的剧情线\n[\s\S]*外门大比/)
+
+  assert.match(fileOf(plan, '大纲/总纲.md'), /剑碎虚空 总纲/)
+})
+
+test('inline → book.yaml、待校对三件、报告', async () => {
+  const plan = await inlinePlan()
+
+  assert.equal(plan.bookName, '剑碎虚空')
+  const yaml = fileOf(plan, 'book.yaml')
+  assert.match(yaml, /书名: 剑碎虚空/)
+  assert.match(yaml, /类型: 仙侠/) // xianxia 码表映射
+
+  const mem = fileOf(plan, '定稿/设定/迁移待校对-记忆清单.md')
+  assert.match(mem, /## open_loops[\s\S]*三长老着人盯梢/)
+  assert.match(mem, /## story_facts[\s\S]*陆家灭门夜唯一遗物/)
+
+  assert.match(fileOf(plan, '文风/迁移待校对-文风候选.md'), /战斗段落短句连用/)
+  assert.match(fileOf(plan, '定稿/设定/迁移待校对-实体变更史.md'), /剑灵反哺/)
+
+  assert.equal(plan.report.counts.章数, 3)
+  assert.equal(plan.report.counts.伏笔, 2)
+  assert.equal(plan.report.counts.角色卡, 2)
+  assert.ok(plan.report.待校对.length >= 3)
+})
+
+test('sqlite → db 实体/摘要/追读力进产物;genre 未知码原样并提示', async () => {
+  const { v6Path, cleanup } = await tempV6Sqlite()
+  try {
+    const r = await readV6Project(v6Path)
+    assert.equal(r.ok, true, r.error)
+    const plan = transformV6(r.facts)
+
+    assert.match(fileOf(plan, 'book.yaml'), /类型: 都市/) // urban 码表
+
+    const ch1 = parseFrontMatter(fileOf(plan, '定稿/正文/0001-退潮.md'))
+    assert.equal(ch1.data.钩子, '悬念钩-强') // readingPower 兜底
+
+    assert.match(fileOf(plan, '定稿/摘要/章摘要/0001.md'), /退潮滩涂拾得停摆怀表/) // db summary 兜底
+    assert.match(fileOf(plan, '定稿/设定/角色/江遥.md'), /滨海市海事记者/)
+    const roster = fileOf(plan, '定稿/设定/名册.md')
+    assert.match(roster, /\| 江遥 \| 小江 \| 角色 \| 1 \|/)
+    assert.match(roster, /\| 滨海市 \|  \| 地点 \| 1 \|/)
+  } finally {
+    await cleanup()
+  }
+})