ソースを参照

fix(v7): P2/S 清扫+G-6 spawn 冒烟——第三轮 review 批3

- storage/adapters: A6 缓存命中回中文键、A9/C3 有界匹配、A10/F-7 角色卡文件名同源、
  A12 时间线按(卷,章)替换、A14/A15/A16 死代码删除、A17 段匹配收紧
- cache: B1 warning 补齐+catch 收窄、B2 UNIQUE 冒泡 ROLLBACK+卷复盘条目查重、
  imagery preserveDerived(决策 D5)
- staging/dto: E3 幂等重跑(决策 D3)、E4 清理失败不谎报、E5 --until 建议、
  E6 打回空目录状态、E8 isWeakHook 单源(util/hooks.js)、D5/D6 readBatch heal 参数+现存口径
- state-machine: D3 区 TRACKED_SOURCE_PREFIXES 三处同源(决策 D6)、D4 retcon 逐文件回滚、
  D7/G-7 章号查询防御
- migrate/export: F-4 码点截断、F-5 mtime 阈值、F-6 删可写回退(决策 D4)、F-8 错误人话化、
  F-9 warning+类型归一复用、F-10 Windows 保留设备名
- bin/docs: G-9 命令名白名单、G-8 删硬编码计数、G-5 files 加 docs/+迁移报告指路
- G-6: bin spawn 冒烟 ×2(export 书仓库直启/migrate 工作目录),兼验 exitCode+错误无栈+cache.close
- 测试 491 全绿(存量 489 + 新增 2;router D3 区用例数据修正:append 不重复键)
lingfengQAQ 1 日 前
コミット
735382bdc5
38 ファイル変更784 行追加362 行削除
  1. 8 1
      v7/bin/webnovel-writer.js
  2. 2 1
      v7/package.json
  3. 12 3
      v7/src/cache/index.js
  4. 156 120
      v7/src/cache/rebuilder.js
  5. 4 2
      v7/src/commands/next.js
  6. 4 3
      v7/src/commands/report-weak-hook-streak.js
  7. 3 3
      v7/src/finalize/index.js
  8. 13 2
      v7/src/migrate/index.js
  9. 37 15
      v7/src/migrate/read-v6.js
  10. 3 1
      v7/src/migrate/transform.js
  11. 2 2
      v7/src/prep/book-status.js
  12. 127 58
      v7/src/staging/index.js
  13. 15 7
      v7/src/state-machine/detectors.js
  14. 8 4
      v7/src/state-machine/dto.js
  15. 5 6
      v7/src/state-machine/flows/goto-chapter.js
  16. 9 3
      v7/src/state-machine/flows/retcon.js
  17. 19 5
      v7/src/state-machine/index.js
  18. 15 0
      v7/src/state-machine/persist.js
  19. 25 28
      v7/src/storage/adapters/ChapterReader.js
  20. 4 2
      v7/src/storage/adapters/EntityReader.js
  21. 8 5
      v7/src/storage/adapters/EntityWriter.js
  22. 2 1
      v7/src/storage/adapters/SecretReader.js
  23. 23 4
      v7/src/storage/adapters/ThreadLedgerReader.js
  24. 2 1
      v7/src/storage/adapters/ThreadLedgerWriter.js
  25. 4 1
      v7/src/storage/adapters/TimelineWriter.js
  26. 1 5
      v7/src/storage/parsers/book-config.js
  27. 5 61
      v7/src/storage/serializers/front-matter.js
  28. 7 2
      v7/src/util/filename.js
  29. 10 0
      v7/src/util/hooks.js
  30. 4 3
      v7/src/util/markdown.js
  31. 68 0
      v7/test/integration/cli-spawn-smoke.test.js
  32. 7 2
      v7/test/migrate/e2e.test.js
  33. 40 0
      v7/test/staging/finalize-batch.test.js
  34. 42 0
      v7/test/staging/index.test.js
  35. 6 0
      v7/test/state-machine/auto-mode.test.js
  36. 61 0
      v7/test/state-machine/router.test.js
  37. 21 9
      v7/test/storage/adapters/ChapterReader.test.js
  38. 2 2
      v7/test/storage/parsers/yaml-safe.test.js

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

@@ -28,7 +28,7 @@ if (command === '--version' || command === '-v') {
 if (!command || command === '--help') {
   console.log('用法:webnovel-writer <命令> [选项]')
   console.log('')
-  console.log('精准读取接口(41 个,分布于 21 个命令;逐条清单见任务 prd.md AC2):')
+  console.log('精准读取接口(逐条清单如下):')
   console.log('  read-chapter <章号> [--front-matter|--tail=N|--head=N|--摘要]')
   console.log('  read-chapters [--range=a-b --摘要|--recent=N --tail=M]')
   console.log('  list-chapters --章定位=推进 [--卷=N]')
@@ -101,6 +101,13 @@ function parseArgs(rest) {
   return { options, positionalArgs }
 }
 
+// 命令名白名单(G-9):命令即 commands/ 下的文件名,路径拼接 import 不给 `../` 之类
+// 输入把报错降级成「mod.run is not a function」——不匹配直接走「未知命令」
+if (!/^[a-z0-9-]+$/.test(command)) {
+  console.error(`未知命令「${command}」。运行 webnovel-writer --help 查看可用命令。`)
+  process.exit(1)
+}
+
 let cache
 try {
   const commandPath = path.join(__dirname, '../src/commands', `${command}.js`)

+ 2 - 1
v7/package.json

@@ -15,7 +15,8 @@
     "roles/",
     "skills/",
     "adapters/",
-    "templates/"
+    "templates/",
+    "docs/"
   ],
   "scripts": {
     "test": "node --test",

+ 12 - 3
v7/src/cache/index.js

@@ -14,7 +14,12 @@ let rebuildCounter = 0
 export async function refreshCacheAfterSourceChange(ctx) {
   if (!ctx.cache) return null
   try {
-    return await ctx.cache.rebuildFromSource(ctx.repoPath, { keepExistingOnFailure: false })
+    // 源变了 → 体检产出的 imagery_top 已陈旧,一并作废(宁可无提醒不给过时提醒),
+    // 下次体检重算;裸重建(删缓存/ensureReady)不经此路径,imagery_top 照常保留。
+    return await ctx.cache.rebuildFromSource(ctx.repoPath, {
+      keepExistingOnFailure: false,
+      preserveDerived: false,
+    })
   } catch (err) {
     return { ok: false, warnings: [], errors: [`缓存刷新失败:${err.message}`] }
   }
@@ -81,11 +86,12 @@ export class CacheManager {
   /**
    * 全量重建缓存。
    * @param {string} repoPath
-   * @param {{keepExistingOnFailure?: boolean}} [opts]
+   * @param {{keepExistingOnFailure?: boolean, preserveDerived?: boolean}} [opts]
+   *   preserveDerived=false 时不保留体检派生物(imagery_top)——改源刷新用,防陈旧提醒
    * @returns {Promise<{ok: boolean, warnings: string[], errors: string[]}>}
    */
   async rebuildFromSource(repoPath, opts = {}) {
-    const { keepExistingOnFailure = true } = opts
+    const { keepExistingOnFailure = true, preserveDerived = true } = opts
     let hadExisting = false
     try {
       await fs.access(this.dbPath)
@@ -110,6 +116,9 @@ export class CacheManager {
         preservedMeta = []
       }
     }
+    if (!preserveDerived) {
+      preservedMeta = preservedMeta.filter((row) => row.key !== 'imagery_top')
+    }
 
     // 关闭现有连接
     if (this.db) {

+ 156 - 120
v7/src/cache/rebuilder.js

@@ -28,13 +28,13 @@ export async function rebuildCache(repoPath, db) {
     db.exec('DELETE FROM fingerprints')
 
     // 2. 扫描章节文件 → 填充 chapters 表
-    const chapterMap = await scanChapters(repoPath, db)
+    const chapterMap = await scanChapters(repoPath, db, warnings)
 
     // 3. 扫描条目文件 → 填充 threads 表(验证履历章节)
     await scanThreads(repoPath, db, chapterMap, warnings)
 
     // 4. 扫描信息差 → 填充 secrets 表
-    await scanSecrets(repoPath, db)
+    await scanSecrets(repoPath, db, warnings)
 
     // 5. 解析名册 → 填充 entities + entity_aliases 表(验证别名唯一性)
     const aliasCheck = await scanEntities(repoPath, db)
@@ -46,7 +46,7 @@ export async function rebuildCache(repoPath, db) {
     }
 
     // 6. 扫描角色卡 → 填充 entities 表
-    await scanCharacters(repoPath, db)
+    await scanCharacters(repoPath, db, warnings)
 
     // 7. fingerprints 由体检按需重算(M5.5),重建不填
 
@@ -65,57 +65,71 @@ export async function rebuildCache(repoPath, db) {
 
 /**
  * 扫描章节文件,填充 chapters 表。
+ * 错误边界(B1/B-S):解析失败 = 跳过 + warning;读文件/主键冲突 = 硬错冒泡触发 ROLLBACK
+ * ——过宽的 try/catch 会让最新章从缓存静默消失,next 读偏小的 MAX(chapter_num) 重抄本章。
  * @returns {Promise<Map<number, string>>} 章号 → 文件路径映射
  */
-async function scanChapters(repoPath, db) {
+async function scanChapters(repoPath, db, warnings) {
   const chapterDir = path.join(repoPath, '定稿', '正文')
   const chapterMap = new Map()
 
+  let files
   try {
-    const files = await fs.readdir(chapterDir)
-    const insertStmt = db.prepare(`
-      INSERT INTO chapters (chapter_num, title, volume_num, perspective, story_time, word_count, chapter_position, hook_type, mood_position, is_volume_end, file_path, is_key_chapter)
-      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
-    `)
+    files = await fs.readdir(chapterDir)
+  } catch (err) {
+    if (err.code === 'ENOENT') return chapterMap // 目录不存在(新书),跳过
+    throw err
+  }
 
-    for (const file of files) {
-      if (!file.endsWith('.md')) continue
+  const insertStmt = db.prepare(`
+    INSERT INTO chapters (chapter_num, title, volume_num, perspective, story_time, word_count, chapter_position, hook_type, mood_position, is_volume_end, file_path, is_key_chapter)
+    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+  `)
 
-      const filePath = path.join(chapterDir, file)
-      const content = await fs.readFile(filePath, 'utf8')
-      const parsed = parseFrontMatter(content)
+  for (const file of files) {
+    if (!file.endsWith('.md')) continue
 
-      if (parsed.ok) {
-        const fm = parsed.data
-        const chapterNum = fm.章号 || parseInt(file.match(/\d+/)?.[0] || '0', 10)
-
-        insertStmt.run(
-          chapterNum,
-          fm.标题 || '未命名',
-          fm.卷 || 1,
-          fm.视角 || null,
-          fm.书内时间 || null,
-          fm.字数 || 0,
-          fm.章定位 || '推进',
-          fm.钩子 || null,
-          fm.情绪定位 || null,
-          fm.收卷 === '是' || fm.收卷 === true ? 1 : 0,
-          filePath,
-          fm.是否关键章 ? 1 : 0
-        )
+    const filePath = path.join(chapterDir, file)
+    const content = await fs.readFile(filePath, 'utf8')
+    const parsed = parseFrontMatter(content)
 
-        chapterMap.set(chapterNum, filePath)
-      }
+    if (!parsed.ok) {
+      warnings.push(`章文件 定稿/正文/${file} 解析失败,未入缓存:${parsed.error}`)
+      continue
     }
-  } catch (err) {
-    // 目录不存在,跳过
+    const fm = parsed.data
+    const chapterNum = fm.章号 || parseInt(file.match(/\d+/)?.[0] || '0', 10)
+
+    if (chapterMap.has(chapterNum)) {
+      throw new Error(
+        `第 ${chapterNum} 章有重复档案:${path.basename(chapterMap.get(chapterNum))} 与 ${file} 同章号,请先处理再重建缓存`
+      )
+    }
+
+    insertStmt.run(
+      chapterNum,
+      fm.标题 || '未命名',
+      fm.卷 || 1,
+      fm.视角 || null,
+      fm.书内时间 || null,
+      fm.字数 || 0,
+      fm.章定位 || '推进',
+      fm.钩子 || null,
+      fm.情绪定位 || null,
+      fm.收卷 === '是' || fm.收卷 === true ? 1 : 0,
+      filePath,
+      fm.是否关键章 ? 1 : 0
+    )
+
+    chapterMap.set(chapterNum, filePath)
   }
 
   return chapterMap
 }
 
 /**
- * 扫描条目文件,填充 threads 表。
+ * 扫描条目文件,填充 threads 表。错误边界同 scanChapters(B1/B2):
+ * 解析失败 = warning;重号(含卷复盘绕过 createThread 写出的撞号文件)= 硬错冒泡。
  */
 async function scanThreads(repoPath, db, chapterMap, warnings) {
   const types = [
@@ -129,49 +143,63 @@ async function scanThreads(repoPath, db, chapterMap, warnings) {
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
   `)
 
+  const seen = new Map()
   for (const { dir, type } of types) {
     const threadDir = path.join(repoPath, '大纲', dir)
 
+    let files
     try {
-      const files = await fs.readdir(threadDir)
-
-      for (const file of files) {
-        if (!file.endsWith('.md')) continue
-
-        const filePath = path.join(threadDir, file)
-        const content = await fs.readFile(filePath, 'utf8')
-        const parsed = parseFrontMatter(content)
-
-        if (parsed.ok) {
-          const fm = parsed.data
-          const id = file.replace('.md', '').split('-').slice(0, 2).join('-') // 伏笔-001
-
-          // 验证履历章节存在性
-          const historySection = extractSection(parsed.body, '履历')
-          if (historySection) {
-            const historyChapters = parseHistoryChapters(historySection)
-            for (const ch of historyChapters) {
-              if (!chapterMap.has(ch)) {
-                warnings.push(`条目 ${id} 履历引用章节 ${ch} 不存在`)
-              }
-            }
-          }
+      files = await fs.readdir(threadDir)
+    } catch (err) {
+      if (err.code === 'ENOENT') continue // 该类目录不存在,跳过
+      throw err
+    }
+
+    for (const file of files) {
+      if (!file.endsWith('.md')) continue
+
+      const filePath = path.join(threadDir, file)
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
 
-          insertStmt.run(
-            id,
-            type,
-            id, // short_title 简化为 id
-            fm.强度 || '中',
-            fm.状态 || '进行',
-            fm.开启章 || 1,
-            fm.预计收尾 || null,
-            fm.最后推进章 || fm.开启章 || 1,
-            filePath
-          )
+      if (!parsed.ok) {
+        warnings.push(`条目文件 大纲/${dir}/${file} 解析失败,未入缓存:${parsed.error}`)
+        continue
+      }
+      const fm = parsed.data
+      const stem = file.replace(/\.md$/, '')
+      const id = stem.split('-').slice(0, 2).join('-') // 伏笔-001
+      const shortTitle = stem.split('-').slice(2).join('-') || id
+
+      if (seen.has(id)) {
+        throw new Error(
+          `条目 ${id} 有重复档案:${seen.get(id)} 与 ${file} 同编号,请先处理再重建缓存`
+        )
+      }
+      seen.set(id, file)
+
+      // 验证履历章节存在性
+      const historySection = extractSection(parsed.body, '履历')
+      if (historySection) {
+        const historyChapters = parseHistoryChapters(historySection)
+        for (const ch of historyChapters) {
+          if (!chapterMap.has(ch)) {
+            warnings.push(`条目 ${id} 履历引用章节 ${ch} 不存在`)
+          }
         }
       }
-    } catch (err) {
-      // 目录不存在,跳过
+
+      insertStmt.run(
+        id,
+        type,
+        shortTitle,
+        fm.强度 || '中',
+        fm.状态 || '进行',
+        fm.开启章 || 1,
+        fm.预计收尾 || null,
+        fm.最后推进章 || fm.开启章 || 1,
+        filePath
+      )
     }
   }
 }
@@ -179,7 +207,7 @@ async function scanThreads(repoPath, db, chapterMap, warnings) {
 /**
  * 扫描信息差,填充 secrets 表。
  */
-async function scanSecrets(repoPath, db) {
+async function scanSecrets(repoPath, db, warnings) {
   const secretDir = path.join(repoPath, '定稿', '设定', '信息差')
 
   const insertStmt = db.prepare(`
@@ -187,35 +215,39 @@ async function scanSecrets(repoPath, db) {
     VALUES (?, ?, ?, ?, ?, ?, ?)
   `)
 
+  let files
   try {
-    const files = await fs.readdir(secretDir)
+    files = await fs.readdir(secretDir)
+  } catch (err) {
+    if (err.code === 'ENOENT') return // 目录不存在,跳过
+    throw err
+  }
 
-    for (const file of files) {
-      if (!file.endsWith('.md')) continue
+  for (const file of files) {
+    if (!file.endsWith('.md')) continue
 
-      const filePath = path.join(secretDir, file)
-      const content = await fs.readFile(filePath, 'utf8')
-      const parsed = parseFrontMatter(content)
+    const filePath = path.join(secretDir, file)
+    const content = await fs.readFile(filePath, 'utf8')
+    const parsed = parseFrontMatter(content)
 
-      if (parsed.ok) {
-        const fm = parsed.data
-        const stem = file.replace('.md', '')
-        const id = stem.split('-').slice(0, 2).join('-')
-        const shortTitle = stem.split('-').slice(2).join('-') || id // 信息差-021-灭门真凶 → 灭门真凶
-
-        insertStmt.run(
-          id,
-          shortTitle,
-          JSON.stringify(fm.知情人 || []),
-          fm.读者已知 ? 1 : 0,
-          fm.登记章 || 1,
-          JSON.stringify(fm.关键词 || []),
-          filePath
-        )
-      }
+    if (!parsed.ok) {
+      warnings.push(`信息差文件 定稿/设定/信息差/${file} 解析失败,未入缓存:${parsed.error}`)
+      continue
     }
-  } catch (err) {
-    // 目录不存在,跳过
+    const fm = parsed.data
+    const stem = file.replace('.md', '')
+    const id = stem.split('-').slice(0, 2).join('-')
+    const shortTitle = stem.split('-').slice(2).join('-') || id // 信息差-021-灭门真凶 → 灭门真凶
+
+    insertStmt.run(
+      id,
+      shortTitle,
+      JSON.stringify(fm.知情人 || []),
+      fm.读者已知 ? 1 : 0,
+      fm.登记章 || 1,
+      JSON.stringify(fm.关键词 || []),
+      filePath
+    )
   }
 }
 
@@ -284,7 +316,7 @@ async function scanEntities(repoPath, db) {
 /**
  * 扫描角色卡,填充 entities 表。
  */
-async function scanCharacters(repoPath, db) {
+async function scanCharacters(repoPath, db, warnings) {
   const charDir = path.join(repoPath, '定稿', '设定', '角色')
 
   // upsert:角色卡可能不在名册里(名册非强制全覆盖),只 UPDATE 会丢这些角色。
@@ -303,32 +335,36 @@ async function scanCharacters(repoPath, db) {
       file_path = excluded.file_path
   `)
 
+  let files
   try {
-    const files = await fs.readdir(charDir)
+    files = await fs.readdir(charDir)
+  } catch (err) {
+    if (err.code === 'ENOENT') return // 目录不存在,跳过
+    throw err
+  }
 
-    for (const file of files) {
-      if (!file.endsWith('.md')) continue
+  for (const file of files) {
+    if (!file.endsWith('.md')) continue
 
-      const name = file.replace('.md', '')
-      const filePath = path.join(charDir, file)
-      const content = await fs.readFile(filePath, 'utf8')
-      const parsed = parseFrontMatter(content)
+    const name = file.replace('.md', '')
+    const filePath = path.join(charDir, file)
+    const content = await fs.readFile(filePath, 'utf8')
+    const parsed = parseFrontMatter(content)
 
-      if (parsed.ok) {
-        const fm = parsed.data
-        upsertStmt.run(
-          name,
-          fm.状态 || null,
-          fm.位置 || null,
-          fm.境界 || null,
-          JSON.stringify(fm.持有 || []),
-          fm.最后变更章 || 1,
-          filePath
-        )
-      }
+    if (!parsed.ok) {
+      warnings.push(`角色卡 定稿/设定/角色/${file} 解析失败,未入缓存:${parsed.error}`)
+      continue
     }
-  } catch (err) {
-    // 目录不存在,跳过
+    const fm = parsed.data
+    upsertStmt.run(
+      name,
+      fm.状态 || null,
+      fm.位置 || null,
+      fm.境界 || null,
+      JSON.stringify(fm.持有 || []),
+      fm.最后变更章 || 1,
+      filePath
+    )
   }
 }
 

+ 4 - 2
v7/src/commands/next.js

@@ -12,11 +12,13 @@ export async function run(args, options, ctx) {
   if (options.json) {
     return { ok: true, output: JSON.stringify(r, null, 2) }
   }
+  if (!r.ok) return { ok: false, error: r.message }
   const lines = []
-  if (r.gitHealth.fixed.length) {
+  // gitHealth 防御(G-7):序1 等无仓库路径的返回不一定带完整 gitHealth
+  if (r.gitHealth?.fixed?.length) {
     lines.push('【git 已自动处理】', ...r.gitHealth.fixed.map((s) => '  · ' + s))
   }
-  if (r.gitHealth.guidance.length) {
+  if (r.gitHealth?.guidance?.length) {
     lines.push('【需你留意】', ...r.gitHealth.guidance.map((s) => '  · ' + s))
   }
   lines.push(`【当前状态】序${r.序} ${r.state}${r.needsAI ? '(需 AI)' : ''}`)

+ 4 - 3
v7/src/commands/report-weak-hook-streak.js

@@ -1,6 +1,8 @@
+import { isWeakHook } from '../util/hooks.js'
+
 /**
  * report-weak-hook-streak → 末尾连续弱钩章数(全书近况 / 机检"连续弱钩上限"用)
- * 钩子值形如"危机钩-强"、"情绪钩-弱",弱钩判定:含"弱钩"或以"-弱"结尾(design §6.3)。
+ * 弱钩判据单源 isWeakHook(E8)。
  * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
  */
 export async function run(args, options, ctx) {
@@ -10,8 +12,7 @@ export async function run(args, options, ctx) {
 
   let streak = 0
   for (const ch of rows) {
-    const h = ch.hook_type || ''
-    if (h.includes('弱钩') || h.endsWith('-弱')) {
+    if (isWeakHook(ch.hook_type)) {
       streak++
     } else {
       break

+ 3 - 3
v7/src/finalize/index.js

@@ -91,9 +91,9 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
     for (const c of characterUpdates) {
       const r = await ew.updateCharacter(c.name, c.updates)
       if (!r.ok) throw new Error(r.error)
-      const filePath = path.join(repoPath, '定稿', '设定', '角色', `${c.name}.md`)
-      stageFiles.push(filePath)
-      rollbackFiles.push(filePath)
+      // 路径取写入器返回值(A10:文件名净化单源,防手拼路径与实际写点分裂)
+      stageFiles.push(r.filePath)
+      rollbackFiles.push(r.filePath)
     }
     for (const row of rosterUpserts) {
       const r = await ew.upsertRosterRow(row)

+ 13 - 2
v7/src/migrate/index.js

@@ -130,16 +130,27 @@ function renderReport(plan, v6Path, dirName) {
     ...(丢弃.length ? 丢弃.map((t) => `- ${t}`) : ['(无)']),
     '',
     '> 校对完成后本报告可删;「迁移待校对-」开头的文件处理完也一并删掉。',
+    '> v6→v7 有哪些变化、迁移映射了什么,见 npm 包内 docs/migration-guide.md。',
     '',
   ]
   return lines.join('\n')
 }
 
+// 残留临时目录的陈旧线(F-5):只清超过 24h 的 .migrate-tmp-*——
+// 同工作目录并行 migrate 时,别的进程正在写的活跃临时目录不得误删
+const TMP_STALE_MS = 24 * 60 * 60 * 1000
+
 async function sweepStaleTmp(workdir) {
   try {
     for (const ent of await fs.readdir(workdir)) {
-      if (ent.startsWith(TMP_PREFIX)) {
-        await fs.rm(path.join(workdir, ent), { recursive: true, force: true })
+      if (!ent.startsWith(TMP_PREFIX)) continue
+      const full = path.join(workdir, ent)
+      try {
+        const st = await fs.stat(full)
+        if (Date.now() - st.mtimeMs < TMP_STALE_MS) continue
+        await fs.rm(full, { recursive: true, force: true })
+      } catch {
+        // 单个残留读不了/删不掉不拦整体
       }
     }
   } catch {

+ 37 - 15
v7/src/migrate/read-v6.js

@@ -33,8 +33,21 @@ export async function readV6Project(v6Path) {
   }
 
   // —— index.db(只读;缺表/缺列容忍,Q13-10)——
+  // 源零写入是硬承诺(F-6/design §3.1):readOnly 打不开就中止迁移,绝不回退可写打开
+  // ——可写打开遇 v6 热日志会触发对源库的恢复写入
   const dbPath = path.join(v6Path, '.webnovel', 'index.db')
-  const db = (await exists(dbPath)) ? openReadOnly(dbPath, warnings) : null
+  let db = null
+  if (await exists(dbPath)) {
+    try {
+      db = new DatabaseSync(dbPath, { readOnly: true })
+    } catch (err) {
+      return {
+        ok: false,
+        facts: null,
+        error: `源数据库 .webnovel/index.db 无法只读打开(${err.message}):可能被别的程序占用,关掉后重试。为守住「迁移绝不改动 v6 源」的承诺,这次没有继续。`,
+      }
+    }
+  }
 
   const hasInlineEntities = state.entities_v3 && Object.keys(state.entities_v3).length > 0
   const form = db && hasInlineEntities ? 'mixed' : db ? 'sqlite' : 'inline'
@@ -200,11 +213,33 @@ async function scanChapters(v6Path, warnings) {
       })
     }
   }
+  // 布局约定外的目录若藏着章形文件,如实告警而非静默漏(F-9)
+  const warnNested = async (dir, label) => {
+    let names
+    try {
+      names = await fs.readdir(dir)
+    } catch {
+      return
+    }
+    if (names.some((f) => /^第\d+章/.test(f) && f.endsWith('.md'))) {
+      warnings.push(
+        `正文/${label}/ 超出「正文/第N卷/第NNN章.md」的布局约定,里面的章节文件未迁移——挪到 正文/ 或卷目录下重跑可补迁。`
+      )
+    }
+  }
   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]))
+    if (vm) {
+      const volDir = path.join(root, ent.name)
+      await collect(volDir, Number(vm[1]))
+      for (const sub of await fs.readdir(volDir, { withFileTypes: true })) {
+        if (sub.isDirectory()) await warnNested(path.join(volDir, sub.name), `${ent.name}/${sub.name}`)
+      }
+    } else {
+      await warnNested(path.join(root, ent.name), ent.name)
+    }
   }
   return [...found.values()].sort((a, b) => a.num - b.num)
 }
@@ -294,19 +329,6 @@ async function readJson(p, warnings, 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()

+ 3 - 1
v7/src/migrate/transform.js

@@ -4,6 +4,7 @@ import { parseMarkdownTable } from '../storage/parsers/markdown-table.js'
 import { serializeYAML } from '../storage/serializers/yaml-dialect.js'
 import { extractSection } from '../util/markdown.js'
 import { sanitizeFileName } from '../util/filename.js'
+import { normalizeEntityType } from '../util/entity-type.js'
 
 const GENRE_MAP = new Map([
   ['xianxia', '仙侠'], ['xiuxian', '仙侠'],
@@ -145,7 +146,8 @@ export function transformV6(facts) {
   }
   let 角色卡 = 0
   for (const e of facts.entities) {
-    if (e.type !== '角色') continue
+    // 类型判定过归一单源(F-9):v6 用英文/机器键(character 等)的真角色也要出卡
+    if (normalizeEntityType(e.type) !== 'character') continue
     const fm = { 姓名: e.name }
     if (e.aliases.length) fm.别名 = e.aliases
     if (e.current.状态) fm.状态 = e.current.状态

+ 2 - 2
v7/src/prep/book-status.js

@@ -1,5 +1,6 @@
 import { ThreadLedgerReader } from '../storage/adapters/ThreadLedgerReader.js'
 import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
+import { isWeakHook } from '../util/hooks.js'
 
 /**
  * 组装全书近况(喂细纲 step1 与备料 step3)。复用 M1 缓存与读端口,派生值查询时算。
@@ -30,8 +31,7 @@ export async function assembleBookStatus(ctx) {
     )
     let 连续弱钩 = 0
     for (const r of hookRows) {
-      const h = r.hook_type || ''
-      if (h.includes('弱钩') || h.endsWith('-弱')) 连续弱钩++
+      if (isWeakHook(r.hook_type)) 连续弱钩++
       else break
     }
 

+ 127 - 58
v7/src/staging/index.js

@@ -6,6 +6,7 @@ import { parseThreadDeclarations, OPENING_VERBS } from '../util/thread-declarati
 import { sanitizeFileName } from '../util/filename.js'
 import { normalizeWorkspaceRel } from '../util/workspace-path.js'
 import { splitAliases } from '../util/aliases.js'
+import { isWeakHook } from '../util/hooks.js'
 import { writeAtomicBatch } from '../storage/atomic.js'
 import { finalizeChapter } from '../finalize/index.js'
 import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
@@ -30,9 +31,11 @@ export const 章状态 = { 待审收: '待审收', 打回: '打回', 受影响:
 
 /**
  * 读批次元数据(含与实际章目录的对账)。
+ * @param {{heal?: boolean}} [opts] heal=false 时批次.json 缺失只重建内存视图不落盘——
+ *   名义只读的路径(next 路由组 DTO)不得隐含写盘(D5)
  * @returns {Promise<{exists: boolean, 起章: number, 章列表: Array<{章号,标题,状态,目录}>, warnings: string[]}>}
  */
-export async function readBatch(repoPath) {
+export async function readBatch(repoPath, { heal = true } = {}) {
   const empty = { exists: false, 起章: 0, 章列表: [], warnings: [] }
   const dir = path.join(repoPath, BATCH_DIR)
   let entries
@@ -76,7 +79,7 @@ export async function readBatch(repoPath) {
   } else if (dirNames.length) {
     warnings.push('批次.json 缺失或损坏,已按章目录重建批次记录;为稳妥起见全部标记「受影响」,请重审后再定稿。')
     for (const d of dirNames) rows.push(await rowFromDir(repoPath, d, warnings))
-    await writeAtomicBatch(repoPath, [metaFile(rows)])
+    if (heal) await writeAtomicBatch(repoPath, [metaFile(rows)])
   }
 
   if (!rows.length) return empty
@@ -87,10 +90,24 @@ export async function readBatch(repoPath) {
 async function rowFromDir(repoPath, dirName, warnings) {
   const num = parseInt(dirName.slice(0, 4), 10)
   let 标题 = dirName.slice(5)
+  const dirP = path.join(repoPath, BATCH_DIR, dirName)
+
+  // 三件套全缺 = rejectFrom 清空后的打回章形态,标「打回」保住 stage-chapter 覆盖通道(E6);
+  // 标「受影响」会成死行——finalize 读不到定稿包、restage 又拒打回章
+  let 全缺 = true
+  for (const f of ['草稿.md', '定稿包.json', '审稿单.md']) {
+    try {
+      await fs.access(path.join(dirP, f))
+      全缺 = false
+      break
+    } catch {
+      // 该工件不存在,继续查下一件
+    }
+  }
+  if (全缺) return { 章号: num, 标题, 状态: 章状态.打回, 目录: dirName }
+
   try {
-    const parsed = parseFrontMatter(
-      await fs.readFile(path.join(repoPath, BATCH_DIR, dirName, '草稿.md'), 'utf8')
-    )
+    const parsed = parseFrontMatter(await fs.readFile(path.join(dirP, '草稿.md'), 'utf8'))
     if (parsed.ok && parsed.data.标题) 标题 = parsed.data.标题
   } catch {
     warnings.push(`章目录 ${dirName} 里的草稿读不出来。`)
@@ -109,11 +126,11 @@ function metaFile(rows) {
  * 叠加视图的事实包:staged 章正文/档案 + 预登记(条目/名册/角色/时间线/信息差)。
  * 全部来自批次文件,供备料/审稿输入/机检合并;无批次时 exists=false 且各集合为空。
  * @param {string} repoPath
- * @param {{before?: number}} [opts] 只取章号 < before 的 staged 章——组装第 K 章材料/审稿/机检时
- *   批内事实只能来自更早的章(重审受影响章时,不许后章事实倒灌)
+ * @param {{before?: number, heal?: boolean}} [opts] 只取章号 < before 的 staged 章——组装第 K 章材料/审稿/机检时
+ *   批内事实只能来自更早的章(重审受影响章时,不许后章事实倒灌);heal 透传 readBatch(D5)
  */
 export async function stagedFacts(repoPath, opts = {}) {
-  const batch = await readBatch(repoPath)
+  const batch = await readBatch(repoPath, { heal: opts.heal !== false })
   const facts = {
     exists: batch.exists,
     chapters: [],
@@ -244,8 +261,7 @@ export function overlayBookStatus(status, facts) {
   let weak = 0
   let broke = false
   for (let i = staged.length - 1; i >= 0; i--) {
-    const h = String(staged[i].frontMatter.钩子 || '')
-    if (h.includes('弱钩') || h.endsWith('-弱')) weak++
+    if (isWeakHook(staged[i].frontMatter.钩子)) weak++
     else {
       broke = true
       break
@@ -351,9 +367,10 @@ export async function stageChapter(ctx, { chapterNum, payload }) {
 
 /**
  * 停止条件四件套(spec §8.1,零 token):写满 / 收卷或卷纲耗尽 / 连续无条目变动 / 批次质检不过线。
+ * @param {{heal?: boolean}} [opts] heal 透传 stagedFacts→readBatch(D5:dto 读路径不落盘自愈)
  * @returns {Promise<{stop: boolean, reasons: string[]}>}
  */
-export async function judgeStop(ctx, batch) {
+export async function judgeStop(ctx, batch, opts = {}) {
   const { repoPath, cache } = ctx
   if (!batch?.exists) return { stop: false, reasons: [] }
   const reasons = []
@@ -362,7 +379,7 @@ export async function judgeStop(ctx, batch) {
   const 批次大小 = cfg.连写批次大小 || 8
   const 无变动上限 = cfg.连写无条目变动上限 || 3
 
-  const facts = await stagedFacts(repoPath)
+  const facts = await stagedFacts(repoPath, { heal: opts.heal })
   const staged = facts.chapters
 
   if (staged.length >= 批次大小) {
@@ -423,8 +440,7 @@ export function judgeBatchQuality(staged, baselineFp, config = {}) {
   const 弱上限 = config.连续弱钩上限 || 3
   let weak = 0
   for (let i = staged.length - 1; i >= 0; i--) {
-    const h = String(staged[i].frontMatter.钩子 || '')
-    if (h.includes('弱钩') || h.endsWith('-弱')) weak++
+    if (isWeakHook(staged[i].frontMatter.钩子)) weak++
     else break
   }
   if (weak >= 弱上限) 原因.push(`批内连续 ${weak} 章弱钩(上限 ${弱上限}),追读力要垮,先人工过一眼节奏`)
@@ -512,65 +528,118 @@ export async function restageReview(repoPath, chapterNum) {
 /**
  * 批量定稿:全部(或 --until 前的)章须为「待审收」;升序逐章 finalizeChapter——
  * 每章独立原子 commit,中途失败停在该章、已入档保留(原子性按章保留)。
+ * 可安全重跑(E3/决策 D3):上次中断留下的「已入档但残留未清」章自动跳过转正、只清残留。
  */
 export async function finalizeBatch(ctx, { until } = {}) {
   const { repoPath } = ctx
-  const batch = await readBatch(repoPath)
-  if (!batch.exists) return { ok: false, 已入档: [], error: '没有进行中的待定稿批次' }
-
-  const inScope = (r) => (until ? r.章号 <= until : true)
-  const 目标 = batch.章列表.filter(inScope)
-  if (!目标.length) return { ok: false, 已入档: [], error: `--until=${until} 范围内没有批内章` }
-
-  const 障碍 = 目标.filter((r) => r.状态 !== 章状态.待审收)
-  if (障碍.length) {
-    const list = 障碍.map((r) => `第 ${r.章号} 章(${r.状态})`).join('、')
-    return {
-      ok: false,
-      已入档: [],
-      error: `批内还有未收口的章:${list}——打回章要重写重暂存、受影响章要重审,都回到「待审收」才能批量定稿`,
+  const 已入档 = []
+  try {
+    const batch = await readBatch(repoPath)
+    if (!batch.exists) return { ok: false, 已入档, error: '没有进行中的待定稿批次' }
+
+    const inScope = (r) => (until ? r.章号 <= until : true)
+    const 目标 = batch.章列表.filter(inScope)
+    if (!目标.length) return { ok: false, 已入档, error: `--until=${until} 范围内没有批内章` }
+
+    // 幂等重跑检测:定稿文件已在且章号 ≤ 缓存最大章 = 上次 finalizeChapter 全链路完成
+    // (缓存刷新在 commit 之后),本次只清残留;也不受状态拦——残留目录被 readBatch
+    // 重建标的「受影响」是保守值,不代表要重审
+    const 已定稿残留 = new Set()
+    for (const row of 目标) {
+      if (await alreadyFinalized(ctx, row.章号)) 已定稿残留.add(row.章号)
     }
-  }
 
-  const 已入档 = []
-  let remaining = [...batch.章列表]
-  for (const row of 目标) {
-    const dirP = path.join(repoPath, BATCH_DIR, row.目录)
-    let payload
-    try {
-      payload = JSON.parse(await fs.readFile(path.join(dirP, '定稿包.json'), 'utf8'))
-    } catch (err) {
+    const 障碍 = 目标.filter((r) => r.状态 !== 章状态.待审收 && !已定稿残留.has(r.章号))
+    if (障碍.length) {
+      const list = 障碍.map((r) => `第 ${r.章号} 章(${r.状态})`).join('、')
       return {
         ok: false,
         已入档,
-        error: `第 ${row.章号} 章定稿包读取失败:${err.message}——之前 ${已入档.length} 章已入档保留,批次剩余原样`,
+        error: `批内还有未收口的章:${list}——打回章要重写重暂存、受影响章要重审,都回到「待审收」才能批量定稿`,
       }
     }
-    // 本章工作区文件在暂存时已清;批次目录由本函数自管,防误删他章工件
-    payload.workspaceFiles = []
-    const r = await finalizeChapter(ctx, payload)
-    if (!r.ok) {
-      return {
-        ok: false,
-        已入档,
-        失败章: row.章号,
-        error: `第 ${row.章号} 章定稿失败:${r.error}——之前 ${已入档.length} 章已入档保留,批次剩余原样,修好后重跑 finalize-batch`,
+
+    let remaining = [...batch.章列表]
+    for (const row of 目标) {
+      const dirP = path.join(repoPath, BATCH_DIR, row.目录)
+
+      if (!已定稿残留.has(row.章号)) {
+        let payload
+        try {
+          payload = JSON.parse(await fs.readFile(path.join(dirP, '定稿包.json'), 'utf8'))
+        } catch (err) {
+          return {
+            ok: false,
+            已入档,
+            error: `第 ${row.章号} 章定稿包读取失败:${err.message}——之前 ${已入档.length} 章已入档保留,批次剩余原样`,
+          }
+        }
+        // 本章工作区文件在暂存时已清;批次目录由本函数自管,防误删他章工件
+        payload.workspaceFiles = []
+        const r = await finalizeChapter(ctx, payload)
+        if (!r.ok) {
+          return {
+            ok: false,
+            已入档,
+            失败章: row.章号,
+            error: `第 ${row.章号} 章定稿失败:${r.error}——之前 ${已入档.length} 章已入档保留,批次剩余原样,修好后重跑 finalize-batch`,
+          }
+        }
+        已入档.push({ 章号: row.章号, commit: r.commitHash })
+      }
+
+      // 先记录后删目录(决策 D3):先把该章行移出批次.json 再删章目录——反过来会在
+      // 「目录已删、记录未更」窗口让已入档章以旧状态复活。清残留失败必须报错止步:
+      // 行已移、目录残留会被 readBatch 按「受影响」重新纳入,静默继续会掩盖残留
+      remaining = remaining.filter((x) => x.章号 !== row.章号)
+      try {
+        if (remaining.length) await writeAtomicBatch(repoPath, [metaFile(remaining)])
+        await fs.rm(dirP, { recursive: true, force: true })
+      } catch (err) {
+        return {
+          ok: false,
+          已入档,
+          失败章: row.章号,
+          error: `第 ${row.章号} 章已入档,但批次残留清理失败:${err.message}——关闭占用该目录的程序后重跑 finalize-batch,已入档的章会自动跳过转正、只清残留`,
+        }
       }
     }
-    已入档.push({ 章号: row.章号, commit: r.commitHash })
-    await fs.rm(dirP, { recursive: true, force: true })
-    remaining = remaining.filter((x) => x.章号 !== row.章号)
-    if (remaining.length) await writeAtomicBatch(repoPath, [metaFile(remaining)])
+
+    let 体检 = ''
+    if (!remaining.length) {
+      await fs.rm(path.join(repoPath, BATCH_DIR), { recursive: true, force: true })
+      // 体检与批次对齐(spec §9:连写中每批次一次);失败不阻断——批次已转正完成
+      const hc = await runHealthCheck(ctx)
+      体检 = hc.ok ? `体检已随批次完成(报告见 工作区/体检报告.md)` : `批末体检没跑成:${hc.error}`
+    }
+    return { ok: true, 已入档, 剩余: remaining.length, 体检, error: '' }
+  } catch (err) {
+    // 兜底防裸栈(E3):finalizeChapter 已 commit 的章保留,指引重跑续走
+    return {
+      ok: false,
+      已入档,
+      error: `批量定稿中断:${err.message}——已入档的 ${已入档.length} 章保留,处理后重跑 finalize-batch 续走`,
+    }
   }
+}
 
-  let 体检 = ''
-  if (!remaining.length) {
-    await fs.rm(path.join(repoPath, BATCH_DIR), { recursive: true, force: true })
-    // 体检与批次对齐(spec §9:连写中每批次一次);失败不阻断——批次已转正完成
-    const hc = await runHealthCheck(ctx)
-    体检 = hc.ok ? `体检已随批次完成(报告见 工作区/体检报告.md)` : `批末体检没跑成:${hc.error}`
+// 决策 D3 的「上次已转正」判据:定稿文件存在 且 章号 ≤ 缓存最大章,二者都在
+// 才认(只看文件会把手工拷贝误判为已定稿;只看缓存会在缓存陈旧时误判)
+async function alreadyFinalized(ctx, chapterNum) {
+  const prefix = String(chapterNum).padStart(4, '0')
+  let files
+  try {
+    files = await fs.readdir(path.join(ctx.repoPath, '定稿', '正文'))
+  } catch {
+    return false
+  }
+  if (!files.some((f) => f.startsWith(`${prefix}-`) && f.endsWith('.md'))) return false
+  try {
+    const rows = await ctx.cache.query('SELECT MAX(chapter_num) AS m FROM chapters')
+    return chapterNum <= (rows[0]?.m || 0)
+  } catch {
+    return false
   }
-  return { ok: true, 已入档, 剩余: remaining.length, 体检, error: '' }
 }
 
 /** 整批丢弃:删工作区批次(未入 git,定稿零变化)。破坏性操作,宿主必须先经作者确认。 */

+ 15 - 7
v7/src/state-machine/detectors.js

@@ -4,6 +4,16 @@ import { parseFrontMatter } from '../storage/parsers/front-matter.js'
 import { parseMarkdownTable } from '../storage/parsers/markdown-table.js'
 import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
 import { createGit } from '../finalize/git.js'
+import { readBatch } from '../staging/index.js'
+
+/** 序2/relink/goto 共用的「跟踪面」(决策 D6):手改检测、relink 补登范围、goto 脏树拒绝
+ * 三处同源判定,防双写漂移。文风铁律与 book.yaml 的序0 修复回写由此走序2 补登入档。 */
+export const TRACKED_SOURCE_PREFIXES = ['定稿/', '大纲/', '文风/']
+
+/** @param {string} p git status porcelain 里的仓库相对路径 */
+export function isTrackedSourcePath(p) {
+  return p === 'book.yaml' || TRACKED_SOURCE_PREFIXES.some((pre) => p.startsWith(pre))
+}
 
 /**
  * 序0:扫描源文件解析失败。清单钉死于 spec 0.9 §10(实现不得自行增减):
@@ -87,7 +97,7 @@ export async function bookMissing(repoPath) {
   }
 }
 
-/** 序2:定稿/大纲 下未登记的手改清单(git 工作树未提交改动,含未跟踪新文件)。
+/** 序2:跟踪面(TRACKED_SOURCE_PREFIXES + book.yaml)下未登记的手改清单(git 工作树未提交改动,含未跟踪新文件)。
  * 供状态机出 dto 变更清单、relink 补登命令圈定 add 范围——检测与执行同源,不双写。 */
 export async function listManualEdits(repoPath) {
   try {
@@ -96,7 +106,7 @@ export async function listManualEdits(repoPath) {
     for (const line of status.split('\n').filter(Boolean)) {
       // porcelain 行「XY path」;重命名是「XY old -> new」,两侧都算手改
       for (const p of line.slice(3).split(' -> ')) {
-        if (p.startsWith('定稿') || p.startsWith('大纲')) paths.push(p)
+        if (isTrackedSourcePath(p)) paths.push(p)
       }
     }
     return paths
@@ -125,11 +135,9 @@ export async function unfinishedWorkDetail(repoPath) {
   const 现存 = files.filter(
     (f) => f.startsWith('草稿') || f === '审稿.md' || f === '细纲.md' || f === '本章写作材料.md'
   )
-  try {
-    if ((await fs.readdir(path.join(ws, '待定稿'))).length) 现存.push('待定稿/')
-  } catch {
-    // 无待定稿
-  }
+  // 待定稿现存与 readBatch.exists 同口径(D6):目录里只有杂项文件不算批次——
+  // 否则 DTO 出「待定稿批次续跑」却无批次明细可跑。heal:false——检测器是只读路径
+  if ((await readBatch(repoPath, { heal: false })).exists) 现存.push('待定稿/')
   const 从哪继续 = 现存.includes('待定稿/')
     ? '待定稿批次续跑'
     : 现存.includes('审稿.md')

+ 8 - 4
v7/src/state-machine/dto.js

@@ -69,14 +69,15 @@ export async function buildDto(ctx, 序, base = {}) {
   }
 }
 
-// 序 3 的待定稿批次明细(无批次时不加字段)
+// 序 3 的待定稿批次明细(无批次时不加字段)。heal:false——路由组包是名义只读路径,
+// 批次.json 缺失时只重建内存视图,不落盘自愈(D5)
 async function batchDetail(ctx, base) {
   if (!(base.现存 || []).includes('待定稿/')) return {}
-  const batch = await readBatch(ctx.repoPath)
+  const batch = await readBatch(ctx.repoPath, { heal: false })
   if (!batch.exists) return {}
   const 打回 = batch.章列表.filter((r) => r.状态 === 章状态.打回).map((r) => r.章号)
   const 受影响 = batch.章列表.filter((r) => r.状态 === 章状态.受影响).map((r) => r.章号)
-  const 停止 = await judgeStop(ctx, batch)
+  const 停止 = await judgeStop(ctx, batch, { heal: false })
   let 建议
   if (打回.length) {
     建议 = `重写打回章(第 ${打回.join('、')} 章):走写章流程后 stage-chapter 覆盖`
@@ -85,7 +86,10 @@ async function batchDetail(ctx, base) {
   } else if (停止.stop) {
     建议 = '批次已停:batch-status 呈报作者批量过稿,裁决后 finalize-batch'
   } else {
-    建议 = `继续批内下一章(第 ${batch.章列表[batch.章列表.length - 1].章号 + 1} 章)`
+    // 批内全部过审且未停:主场景是连写中断续写;但作者若刚用 --until 先发过前段,
+    // 剩余章已是裁决后的留存,指路转正而不只是闷头堆章(E5)
+    const 下一章 = batch.章列表[batch.章列表.length - 1].章号 + 1
+    建议 = `继续批内下一章(第 ${下一章} 章);批内 ${batch.章列表.length} 章均已过审,若作者要先发这批,可 finalize-batch 直接转正`
   }
   return {
     批次: {

+ 5 - 6
v7/src/state-machine/flows/goto-chapter.js

@@ -2,6 +2,7 @@ import { createGit } from '../../finalize/git.js'
 import { checkGitHealth } from '../git-health.js'
 import { refreshCacheAfterSourceChange } from '../../cache/index.js'
 import { readBatch } from '../../staging/index.js'
+import { isTrackedSourcePath } from '../detectors.js'
 
 /**
  * 回到第 N 章(spec §9,git 回滚包装)。执行前展示影响范围 + 作者确认;
@@ -47,19 +48,17 @@ export async function gotoChapter(ctx, { chapterNum, confirm = false } = {}) {
   }
 
   // P1-6:reset --hard 前检查脏树。rescue ref 只存 HEAD 指针不含工作树,
-  // 定稿/大纲 若有未提交手改会被静默抹掉且无法找回 → 拒绝,要作者先 commit/stash。
+  // 跟踪面(定稿/大纲/文风/book.yaml,与序2 同源判定)若有未提交手改会被静默抹掉
+  // 且无法找回 → 拒绝,要作者先补登(relink)再回退。
   const status = await git.status()
   const dirtyScoped = status
     .split('\n')
     .filter(Boolean)
-    .some((l) => {
-      const p = l.slice(3)
-      return p.startsWith('定稿') || p.startsWith('大纲')
-    })
+    .some((l) => l.slice(3).split(' -> ').some(isTrackedSourcePath))
   if (dirtyScoped) {
     return {
       ok: false,
-      error: '定稿/大纲 有未登记的手改,reset --hard 会丢弃且无法从救援 ref 找回。请先 commit 或 stash 再回退。',
+      error: '定稿/大纲/文风/book.yaml 有未登记的手改,reset --hard 会丢弃且无法从救援 ref 找回。先跑 relink 补登(或作者自行处理)再回退。',
       gitHealth,
     }
   }

+ 9 - 3
v7/src/state-machine/flows/retcon.js

@@ -22,7 +22,8 @@ export async function retcon(ctx, { chapterNum, 原因, characterUpdates = [], t
     for (const c of characterUpdates) {
       const r = await ew.updateCharacter(c.name, c.updates)
       if (!r.ok) throw new Error(r.error)
-      written.push(path.join(repoPath, '定稿', '设定', '角色', `${c.name}.md`))
+      // 路径取写入器返回值(A10 同源):手拼在名字含可净化字符时会与实际写点分裂
+      written.push(r.filePath)
     }
 
     const tlw = new ThreadLedgerWriter(repoPath)
@@ -44,9 +45,14 @@ export async function retcon(ctx, { chapterNum, 原因, characterUpdates = [], t
     const cacheRefresh = await refreshCacheAfterSourceChange(ctx)
     return { ok: true, commitHash, cacheRefresh, message: `已吃书并留痕:retcon(${chapterNum}): ${原因}` }
   } catch (err) {
+    // 回滚收窄到本次写入集合(D4,对齐 finalize 模式):整棵 定稿/大纲 restore+clean
+    // 会误伤序2 在场的作者手改与无关未跟踪新文件。逐文件 restore——retcon 只更新既有
+    // 文件,不会有未跟踪路径让整条 restore 报错。
     try {
-      await git.restore(['定稿/', '大纲/'])
-      await git.clean(['定稿/', '大纲/'])
+      const rel = [...new Set(written)].map((f) => path.relative(repoPath, f))
+      for (const r of rel) {
+        await git.restore([r])
+      }
     } catch {
       // 回滚尽力而为
     }

+ 19 - 5
v7/src/state-machine/index.js

@@ -42,11 +42,25 @@ export async function determineNextState(ctx) {
     return mk(3, 'resume', false, `工作区有未完成的流程(${unfinished.现存.join('、')}),从「${unfinished.从哪继续}」继续。`, gitHealth, await buildDto(ctx, 3, unfinished))
   }
 
-  // 序4/5/6 需章号信息
-  const lastRows = await cache.query(
-    'SELECT chapter_num, volume_num, is_volume_end FROM chapters ORDER BY chapter_num DESC LIMIT 1'
-  )
-  const last = lastRows[0] || null
+  // 序4/5/6 需章号信息。查询失败不裸抛也不降级成「第 1 章」(D7)——
+  // 假 maxChapter=0 会引导重抄第 1 章,比报错更危险
+  let last = null
+  try {
+    const lastRows = await cache.query(
+      'SELECT chapter_num, volume_num, is_volume_end FROM chapters ORDER BY chapter_num DESC LIMIT 1'
+    )
+    last = lastRows[0] || null
+  } catch (err) {
+    return {
+      ok: false,
+      序: null,
+      state: 'cache-error',
+      needsAI: false,
+      gitHealth,
+      dto: {},
+      message: `缓存查询失败(${err.message}):删除书目录下 .cache/ 后重跑 next,会自动重建缓存。`,
+    }
+  }
   const maxChapter = last?.chapter_num || 0
   const config = await new BookConfigReader(repoPath).read()
   const 体检周期 = (config.ok && config.data.体检周期) || 50

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

@@ -95,6 +95,21 @@ export async function persistVolumeReview(ctx, { 卷号, 卷摘要, 下卷卷纲
       const next = String(卷号 + 1).padStart(2, '0')
       files.push({ path: path.join('大纲', '卷纲', `第${next}卷.md`), content: 下卷卷纲 })
     }
+    // 写前查重(B2):本函数绕过 createThread 直接落文件,撞号会在下次缓存重建时
+    // 才被发现(threads 主键冲突整库回滚)。有界匹配口径与 createThread 一致。
+    if (伏笔条目.length) {
+      let existing = []
+      try {
+        existing = await fs.readdir(path.join(ctx.repoPath, '大纲', '伏笔'))
+      } catch {
+        // 目录不存在,首批条目
+      }
+      for (const e of 伏笔条目) {
+        if (existing.some((f) => f === `${e.id}.md` || f.startsWith(`${e.id}-`))) {
+          return { ok: false, written: [], error: `伏笔条目 ${e.id} 已存在,开新条目须用新编号` }
+        }
+      }
+    }
     for (const e of 伏笔条目) {
       const body = `---\n${serializeYAML(e.frontMatter || {})}\n---\n${e.body || ''}`
       files.push({ path: path.join('大纲', '伏笔', `${e.id}.md`), content: body })

+ 25 - 28
v7/src/storage/adapters/ChapterReader.js

@@ -2,6 +2,29 @@ import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { parseFrontMatter } from '../parsers/front-matter.js'
 
+// 缓存行(英文 snake_case 列)→ 作者域中文键(A6:接口形状不得随缓存冷热漂移)。
+// file_path/is_key_chapter 是机器域内部列,不出作者域。
+const CHAPTER_ROW_TO_FM = {
+  chapter_num: '章号',
+  title: '标题',
+  volume_num: '卷',
+  perspective: '视角',
+  story_time: '书内时间',
+  word_count: '字数',
+  chapter_position: '章定位',
+  hook_type: '钩子',
+  mood_position: '情绪定位',
+}
+
+function chapterRowToFrontMatter(row) {
+  const fm = {}
+  for (const [col, key] of Object.entries(CHAPTER_ROW_TO_FM)) {
+    if (row[col] !== null && row[col] !== undefined) fm[key] = row[col]
+  }
+  if (row.is_volume_end) fm.收卷 = '是'
+  return fm
+}
+
 /**
  * ChapterReader:读取章节 front matter、正文、范围。
  */
@@ -12,7 +35,7 @@ export class ChapterReader {
   }
 
   /**
-   * 读取章节 front matter。
+   * 读取章节 front matter(缓存命中与文件降级输出同一形状:中文键)
    * @param {number} chapterNum - 章号
    * @returns {Promise<{ok: boolean, data: object|null, error: string}>}
    */
@@ -25,7 +48,7 @@ export class ChapterReader {
           [chapterNum]
         )
         if (rows.length > 0) {
-          return { ok: true, data: rows[0], error: '' }
+          return { ok: true, data: chapterRowToFrontMatter(rows[0]), error: '' }
         }
       } catch (err) {
         // 缓存失败,降级到文件读取
@@ -117,32 +140,6 @@ export class ChapterReader {
     return { ok: true, text: head, error: '' }
   }
 
-  /**
-   * 批量读取章节范围,返回指定字段。
-   * @param {number} startChapter
-   * @param {number} endChapter
-   * @param {string[]} fields - 字段列表(默认 ['摘要'])
-   * @returns {Promise<{ok: boolean, chapters: object[], error: string}>}
-   */
-  async readRange(startChapter, endChapter, fields = ['摘要']) {
-    // 简化实现:逐个读取 front matter
-    const chapters = []
-    for (let i = startChapter; i <= endChapter; i++) {
-      const result = await this.readFrontMatter(i)
-      if (result.ok) {
-        const filtered = { 章号: i }
-        for (const field of fields) {
-          if (field in result.data) {
-            filtered[field] = result.data[field]
-          }
-        }
-        chapters.push(filtered)
-      }
-    }
-
-    return { ok: true, chapters, error: '' }
-  }
-
   /**
    * 查找章节文件路径(零填充前缀匹配)。
    * @param {number} chapterNum

+ 4 - 2
v7/src/storage/adapters/EntityReader.js

@@ -3,6 +3,7 @@ import path from 'node:path'
 import { parseFrontMatter } from '../parsers/front-matter.js'
 import { parseMarkdownTable } from '../parsers/markdown-table.js'
 import { splitAliases } from '../../util/aliases.js'
+import { sanitizeFileName } from '../../util/filename.js'
 
 /**
  * EntityReader:读取角色卡、解析别名。
@@ -19,7 +20,8 @@ export class EntityReader {
    * @returns {Promise<{ok: boolean, data: object|null, error: string}>}
    */
   async readCharacterFrontMatter(name) {
-    const filePath = path.join(this.repoPath, '定稿', '设定', '角色', `${name}.md`)
+    // 文件名与 EntityWriter/migrate 同源净化(A10/F-7)
+    const filePath = path.join(this.repoPath, '定稿', '设定', '角色', `${sanitizeFileName(name)}.md`)
 
     try {
       const content = await fs.readFile(filePath, 'utf8')
@@ -39,7 +41,7 @@ export class EntityReader {
    * @returns {Promise<{ok: boolean, frontMatter: object|null, body: string, error: string}>}
    */
   async readCharacterFull(name) {
-    const filePath = path.join(this.repoPath, '定稿', '设定', '角色', `${name}.md`)
+    const filePath = path.join(this.repoPath, '定稿', '设定', '角色', `${sanitizeFileName(name)}.md`)
 
     try {
       const content = await fs.readFile(filePath, 'utf8')

+ 8 - 5
v7/src/storage/adapters/EntityWriter.js

@@ -4,6 +4,7 @@ import { parseFrontMatter } from '../parsers/front-matter.js'
 import { serializeFrontMatter } from '../serializers/front-matter.js'
 import { parseMarkdownTable } from '../parsers/markdown-table.js'
 import { serializeMarkdownTable } from '../serializers/markdown-table.js'
+import { sanitizeFileName } from '../../util/filename.js'
 
 const ROSTER_HEADERS = ['正名', '别名', '类型', '首现章']
 
@@ -18,20 +19,22 @@ export class EntityWriter {
 
   /**
    * 更新角色卡 front matter(合并 updates,保留正文)。
+   * 文件名与 migrate/ChapterWriter 同源过 sanitizeFileName(A10/F-7:名字含可净化
+   * 字符时读写不同名会找不到卡)。返回 filePath 供定稿 stage 用,消除路径双源。
    * @param {string} name 正名
    * @param {object} updates 位置/状态/境界/持有/最后变更章 等
-   * @returns {Promise<{ok: boolean, error: string}>}
+   * @returns {Promise<{ok: boolean, filePath: string, error: string}>}
    */
   async updateCharacter(name, updates) {
-    const filePath = path.join(this.repoPath, '定稿', '设定', '角色', `${name}.md`)
+    const filePath = path.join(this.repoPath, '定稿', '设定', '角色', `${sanitizeFileName(name)}.md`)
     try {
       const parsed = parseFrontMatter(await fs.readFile(filePath, 'utf8'))
-      if (!parsed.ok) return { ok: false, error: `角色 ${name} 解析失败:${parsed.error}` }
+      if (!parsed.ok) return { ok: false, filePath: '', error: `角色 ${name} 解析失败:${parsed.error}` }
       const merged = { ...parsed.data, ...updates }
       await fs.writeFile(filePath, serializeFrontMatter(merged, parsed.body), 'utf8')
-      return { ok: true, error: '' }
+      return { ok: true, filePath, error: '' }
     } catch (err) {
-      return { ok: false, error: `角色 ${name} 不存在或写入失败:${err.message}` }
+      return { ok: false, filePath: '', error: `角色 ${name} 不存在或写入失败:${err.message}` }
     }
   }
 

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

@@ -112,7 +112,8 @@ export class SecretReader {
     const secretDir = path.join(this.repoPath, '定稿', '设定', '信息差')
     try {
       const files = await fs.readdir(secretDir)
-      const found = files.find((file) => file.startsWith(id))
+      // 有界匹配(C3):信息差-1 不得命中 信息差-10-*.md
+      const found = files.find((file) => file === `${id}.md` || file.startsWith(`${id}-`))
       return found ? path.join(secretDir, found) : null
     } catch (err) {
       return null

+ 23 - 4
v7/src/storage/adapters/ThreadLedgerReader.js

@@ -2,6 +2,24 @@ import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { parseFrontMatter } from '../parsers/front-matter.js'
 
+// 缓存行 → 作者域中文键(A6:形状不随缓存冷热漂移)。
+// type/short_title/file_path 是机器域/文件名派生列,不出作者域。
+const THREAD_ROW_TO_FM = {
+  strength: '强度',
+  status: '状态',
+  opened_chapter: '开启章',
+  planned_end: '预计收尾',
+  last_advanced_chapter: '最后推进章',
+}
+
+function threadRowToFrontMatter(row) {
+  const fm = { id: row.id }
+  for (const [col, key] of Object.entries(THREAD_ROW_TO_FM)) {
+    if (row[col] !== null && row[col] !== undefined) fm[key] = row[col]
+  }
+  return fm
+}
+
 /**
  * ThreadLedgerReader:读取三类条目(伏笔/悬念/感情线)。
  */
@@ -12,7 +30,7 @@ export class ThreadLedgerReader {
   }
 
   /**
-   * 读取条目基本信息。
+   * 读取条目基本信息(缓存命中与文件降级输出同一形状:中文键 + id)
    * @param {string} threadId - 条目 ID(如 "伏笔-001")
    * @returns {Promise<{ok: boolean, data: object|null, error: string}>}
    */
@@ -25,7 +43,7 @@ export class ThreadLedgerReader {
           [threadId]
         )
         if (rows.length > 0) {
-          return { ok: true, data: rows[0], error: '' }
+          return { ok: true, data: threadRowToFrontMatter(rows[0]), error: '' }
         }
       } catch (err) {
         // 降级到文件读取
@@ -44,7 +62,7 @@ export class ThreadLedgerReader {
       if (!parsed.ok) {
         return { ok: false, data: null, error: `解析失败:${parsed.error}` }
       }
-      return { ok: true, data: parsed.data, error: '' }
+      return { ok: true, data: { id: threadId, ...parsed.data }, error: '' }
     } catch (err) {
       return { ok: false, data: null, error: err.message }
     }
@@ -210,7 +228,8 @@ export class ThreadLedgerReader {
 
     try {
       const files = await fs.readdir(threadDir)
-      const found = files.find((file) => file.startsWith(threadId))
+      // 有界匹配(A9):伏笔-1 不得命中 伏笔-10-*.md,口径与 createThread 查重一致
+      const found = files.find((file) => file === `${threadId}.md` || file.startsWith(`${threadId}-`))
       return found ? path.join(threadDir, found) : null
     } catch (err) {
       return null

+ 2 - 1
v7/src/storage/adapters/ThreadLedgerWriter.js

@@ -96,7 +96,8 @@ export class ThreadLedgerWriter {
     const dir = path.join(this.repoPath, '大纲', type)
     try {
       const files = await fs.readdir(dir)
-      const found = files.find((f) => f.startsWith(threadId))
+      // 有界匹配(A9):与 createThread 查重同口径,伏笔-1 不得改到 伏笔-10 的文件
+      const found = files.find((f) => f === `${threadId}.md` || f.startsWith(`${threadId}-`))
       return found ? path.join(dir, found) : null
     } catch {
       return null

+ 4 - 1
v7/src/storage/adapters/TimelineWriter.js

@@ -38,7 +38,10 @@ export class TimelineWriter {
         // 文件不存在,用默认表头
       }
 
-      rows.push(row)
+      // 同章已有行则替换(A12:定稿失败重试/goto 后重定稿同章,时间线不得叠重复行)
+      const idx = rows.findIndex((r) => String(r.章) === String(row.章))
+      if (idx >= 0) rows[idx] = { ...rows[idx], ...row }
+      else rows.push(row)
       await fs.writeFile(filePath, serializeMarkdownTable(headers, rows), 'utf8')
       return { ok: true, filePath, error: '' }
     } catch (err) {

+ 1 - 5
v7/src/storage/parsers/book-config.js

@@ -16,11 +16,7 @@ export function parseBookConfig(yamlString) {
     }
   }
 
-  // 验证必需字段(spec §3)
-  const requiredFields = ['spec_version', '书名', '类型', '每章目标字数', '卷规模']
-  const missingFields = requiredFields.filter((field) => !(field in result.data))
-
-  // 默认值(无论是否缺少必需字段,都合并可选字段的默认值)
+  // 默认值(缺字段一律套默认——book.yaml 大部分字段可选,运行时永远给全量配置)
   const defaults = {
     spec_version: '7.0',
     书名: '未命名',

+ 5 - 61
v7/src/storage/serializers/front-matter.js

@@ -2,68 +2,12 @@ import { serializeYAML } from './yaml-dialect.js'
 
 /**
  * 组装 front matter + 正文为完整 Markdown 文件。
- * 保留未知字段:从 originalYAML 提取非 data 中的字段,拼接到输出。
- * @param {object} data - 已知字段对象
+ * 未知字段的保留由读侧达成:parseFrontMatter 把全部键放进 data,
+ * 更新方以 `{...parsed.data, ...updates}` 展开即不丢字段(§4.5)。
+ * @param {object} data - 全量字段对象
  * @param {string} body - Markdown 正文
- * @param {string} originalYAML - 原始 YAML 字符串(可选,用于保留未知字段)
  * @returns {string} 完整 Markdown 文件内容
  */
-export function serializeFrontMatter(data, body, originalYAML = '') {
-  let yamlContent = serializeYAML(data)
-
-  // 如果有原始 YAML,尝试保留未知字段
-  if (originalYAML) {
-    const unknownFields = extractUnknownFields(originalYAML, data)
-    if (unknownFields.length > 0) {
-      yamlContent += '\n' + unknownFields.join('\n')
-    }
-  }
-
-  return `---\n${yamlContent}\n---\n${body}`
-}
-
-/**
- * 从原始 YAML 中提取未知字段(不在 data 中的字段)。
- * @param {string} originalYAML
- * @param {object} data - 已知字段
- * @returns {string[]} 未知字段行数组
- */
-function extractUnknownFields(originalYAML, data) {
-  const knownKeys = new Set(Object.keys(data))
-  const unknownLines = []
-
-  const lines = originalYAML.split('\n')
-  let i = 0
-  while (i < lines.length) {
-    const line = lines[i]
-    const trimmed = line.trim()
-
-    // 跳过空行和注释
-    if (trimmed === '' || trimmed.startsWith('#')) {
-      i++
-      continue
-    }
-
-    // 解析键(支持 "key:" 和 "key: value" 两种形式)
-    const match = trimmed.match(/^([^:]+):/)
-    if (match) {
-      const key = match[1].trim()
-      if (!knownKeys.has(key)) {
-        // 未知字段,保留整行
-        unknownLines.push(line)
-
-        // 如果是列表字段,保留后续的列表项
-        i++
-        while (i < lines.length && lines[i].trim().startsWith('-')) {
-          unknownLines.push(lines[i])
-          i++
-        }
-        continue
-      }
-    }
-
-    i++
-  }
-
-  return unknownLines
+export function serializeFrontMatter(data, body) {
+  return `---\n${serializeYAML(data)}\n---\n${body}`
 }

+ 7 - 2
v7/src/util/filename.js

@@ -1,5 +1,10 @@
-/** 文件名净化:Windows 非法字符 <>:"/\|?* 与控制字符替成 _(标题本体不改,只净化文件名)。 */
+// Windows 保留设备名(F-10):基名(首个点之前)命中即整名非法,CON.md 也建不出来
+const WINDOWS_RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
+
+/** 文件名净化:Windows 非法字符 <>:"/\|?* 与控制字符替成 _(标题本体不改,只净化文件名);
+ * 保留设备名前置 _ 消歧。 */
 export function sanitizeFileName(title) {
   const s = String(title).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/\s+/g, ' ').trim()
-  return s || '未命名'
+  if (!s) return '未命名'
+  return WINDOWS_RESERVED.test(s.split('.')[0]) ? `_${s}` : s
 }

+ 10 - 0
v7/src/util/hooks.js

@@ -0,0 +1,10 @@
+/**
+ * 弱钩判据单源(E8):front matter「钩子」字段形如「危机钩-强」「日常钩-弱」或含「弱钩」。
+ * 判据变更只改这里——此前四处逐字复制,漏改一处即判据漂移。
+ * @param {string|undefined} hook front matter 钩子字段原文
+ * @returns {boolean}
+ */
+export function isWeakHook(hook) {
+  const h = String(hook || '')
+  return h.includes('弱钩') || h.endsWith('-弱')
+}

+ 4 - 3
v7/src/util/markdown.js

@@ -22,9 +22,10 @@ export function extractSection(content, title) {
 }
 
 /**
- * 在首个标题含 sectionTitle 的 ## 小节段尾追加一行(段不存在则在文末新建该段)。
+ * 在标题精确等于 sectionTitle 的 ## 小节段尾追加一行(段不存在则在文末新建该段)。
+ * 精确匹配而非子串(A17):「履历」不得命中「补充履历」段。
  * @param {string} body Markdown 正文
- * @param {string} sectionTitle 小节标题关键词
+ * @param {string} sectionTitle 小节标题(与 `## 标题` 全等比较,两侧空白容忍)
  * @param {string} line 要追加的整行(如 "- 第152章:推进——...")
  * @returns {string} 追加后的正文
  */
@@ -32,7 +33,7 @@ export function appendUnderSection(body, sectionTitle, line) {
   const lines = body.split('\n')
   let secIdx = -1
   for (let i = 0; i < lines.length; i++) {
-    if (lines[i].startsWith('## ') && lines[i].includes(sectionTitle)) {
+    if (lines[i].startsWith('## ') && lines[i].slice(3).trim() === sectionTitle) {
       secIdx = i
       break
     }

+ 68 - 0
v7/test/integration/cli-spawn-smoke.test.js

@@ -0,0 +1,68 @@
+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 { fileURLToPath } from 'node:url'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { tempV6, inlineFixture } from '../migrate/_v6.js'
+
+const exec = promisify(execFile)
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const BIN = path.join(__dirname, '../../bin/webnovel-writer.js')
+const fixtureRoot = path.join(__dirname, '../fixtures/sample-book')
+
+/**
+ * G-6:M6/M7 新命令的 bin spawn 冒烟——参数解析、exitCode、错误人话(不带栈)。
+ * 全走真子进程;finally 的 rm 兼作 cache.close 探针(db 未关 Windows 会 EBUSY/EPERM)。
+ */
+
+test('bin spawn 冒烟:export 单章(书仓库直启)+ 坏参数人话退出', async () => {
+  const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-spawn-book-'))
+  await fs.cp(fixtureRoot, repo, { recursive: true })
+  const run = (args) => exec(process.execPath, [BIN, ...args], { cwd: repo, encoding: 'utf8' })
+  try {
+    const r = await run(['export', '1'])
+    assert.match(r.stdout, /第0001章-开局\.txt/)
+    const exported = await fs.readFile(path.join(repo, '工作区', '导出', '第0001章-开局.txt'), 'utf8')
+    assert.ok(exported.length > 0)
+    assert.ok(!exported.startsWith('---'), '导出应剥 front matter')
+
+    const err = await run(['export', 'abc']).then(
+      () => null,
+      (e) => e
+    )
+    assert.ok(err, '坏参数应非零退出')
+    assert.equal(err.code, 1)
+    assert.match(err.stderr, /用法/)
+    assert.ok(!/\n\s+at /.test(err.stderr), `错误不带栈:${err.stderr}`)
+  } finally {
+    await fs.rm(repo, { recursive: true, force: true })
+  }
+})
+
+test('bin spawn 冒烟:migrate v6 项目(工作目录)+ 缺参数人话退出', async () => {
+  const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-spawn-workdir-'))
+  await fs.mkdir(path.join(workdir, '.webnovel'), { recursive: true })
+  const v6 = await tempV6(inlineFixture)
+  const run = (args) => exec(process.execPath, [BIN, ...args], { cwd: workdir, encoding: 'utf8' })
+  try {
+    const r = await run(['migrate', v6.v6Path])
+    assert.match(r.stdout, /迁移报告/)
+    await fs.access(path.join(workdir, '剑碎虚空', 'book.yaml'))
+    await fs.access(path.join(workdir, '剑碎虚空', '工作区', '迁移报告.md'))
+
+    const err = await run(['migrate']).then(
+      () => null,
+      (e) => e
+    )
+    assert.ok(err, '缺参数应非零退出')
+    assert.equal(err.code, 1)
+    assert.match(err.stderr, /用法/)
+    assert.ok(!/\n\s+at /.test(err.stderr), `错误不带栈:${err.stderr}`)
+  } finally {
+    await fs.rm(workdir, { recursive: true, force: true })
+    await v6.cleanup()
+  }
+})

+ 7 - 2
v7/test/migrate/e2e.test.js

@@ -162,15 +162,20 @@ test('AC4 回退演练:中途失败工作目录零残留、源 v6 未被改动
   const v6 = await tempV6(inlineFixture)
   try {
     const before = await treeFingerprint(v6.v6Path)
-    // 预埋一个上次中断的残留临时目录
+    // 预埋残留:一个陈旧(拨回 25h 前,应被清扫)、一个新鲜(模拟并行迁移在写,F-5 不得误删)
     await fs.mkdir(path.join(ctx.workdir, '.migrate-tmp-99999', '定稿'), { recursive: true })
+    const stale = new Date(Date.now() - 25 * 60 * 60 * 1000)
+    await fs.utimes(path.join(ctx.workdir, '.migrate-tmp-99999'), stale, stale)
+    await fs.mkdir(path.join(ctx.workdir, '.migrate-tmp-88888'), { recursive: true })
 
     const r = await migrateV6(ctx, v6.v6Path, { _faultBeforeRename: true })
     assert.equal(r.ok, false)
     assert.match(r.error, /工作目录已恢复原样/)
 
     const left = await fs.readdir(ctx.workdir)
-    assert.ok(!left.some((n) => n.startsWith('.migrate-tmp-')), `残留:${left}`)
+    assert.ok(!left.includes('.migrate-tmp-99999'), `陈旧残留未被清扫:${left}`)
+    assert.ok(left.includes('.migrate-tmp-88888'), '新鲜临时目录(可能是并行迁移在写)不得误删')
+    assert.ok(!left.includes(`.migrate-tmp-${process.pid}`), `本次迁移自身的临时目录未清:${left}`)
     assert.ok(!left.includes('剑碎虚空'))
     assert.deepEqual(await treeFingerprint(v6.v6Path), before) // 源逐文件未动
   } finally {

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

@@ -249,3 +249,43 @@ test('finalize-batch --until:只转正前段,剩余待审收、next 报批
     await cleanup()
   }
 })
+
+// E3/决策 D3:转正循环先移批次记录再删目录;残留清理失败人话止步(不裸栈、不谎报);
+// 重跑时「已入档但残留未清」的章自动跳过转正——不二次 commit、不撞「已存在」
+test('E3 清残留失败止步(已入档保留),重跑幂等续走、无重复 commit', async (t) => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    for (const n of [3, 4]) await stage(ctx, n)
+
+    const realRm = fs.rm.bind(fs)
+    const rmMock = t.mock.method(fs, 'rm', async (target, opts) => {
+      if (String(target).includes('0003-')) {
+        throw Object.assign(new Error('注入:目录被占用'), { code: 'EPERM' })
+      }
+      return realRm(target, opts)
+    })
+    const r1 = await finalizeBatch(ctx)
+    rmMock.mock.restore()
+
+    assert.equal(r1.ok, false)
+    assert.deepEqual(r1.已入档.map((x) => x.章号), [3], '第 3 章 commit 已发生,必须如实报已入档')
+    assert.match(r1.error, /已入档/)
+    assert.match(r1.error, /重跑 finalize-batch/)
+
+    // 中断现场:第 3 章行已移出批次.json(先记录后删目录),残留目录还在
+    const mid = await readBatch(ctx.repoPath, { heal: false })
+    assert.ok(mid.章列表.some((r) => r.章号 === 3), '残留目录被按「受影响」重新纳入(对账如实)')
+
+    const git = createGit(ctx.repoPath)
+    const r2 = await finalizeBatch(ctx)
+    assert.equal(r2.ok, true, r2.error)
+    assert.deepEqual(r2.已入档.map((x) => x.章号), [4], '重跑只转正第 4 章,第 3 章跳过')
+
+    const log = await git.log()
+    assert.equal(log.split('ch(3)').length - 1, 1, '第 3 章恰一个 commit,无二次定稿')
+    assert.equal(log.split('ch(4)').length - 1, 1)
+    assert.equal((await readBatch(ctx.repoPath)).exists, false, '批次收口干净')
+  } finally {
+    await cleanup()
+  }
+})

+ 42 - 0
v7/test/staging/index.test.js

@@ -323,3 +323,45 @@ test('stagedFacts:声明与定稿包合并出条目/名册/时间线/信息差
     await cleanup()
   }
 })
+
+// E6:批次.json 损坏重建时,三件套全缺的目录是 rejectFrom 清空后的打回章——
+// 标「打回」保住 stage-chapter 覆盖通道,标「受影响」会成不可操作的死行
+test('批次.json 损坏重建:三件套全缺的目录标「打回」,有工件的标「受影响」', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles({ 批次大小: 8 }))
+  try {
+    assert.equal((await stage(ctx, 3)).ok, true)
+    assert.equal((await stage(ctx, 4)).ok, true)
+    const rej = await rejectFrom(ctx.repoPath, 3)
+    assert.equal(rej.ok, true, rej.error)
+
+    await fs.rm(path.join(ctx.repoPath, '工作区', '待定稿', '批次.json'), { force: true })
+    const batch = await readBatch(ctx.repoPath)
+    assert.equal(batch.exists, true)
+    const 三 = batch.章列表.find((r) => r.章号 === 3)
+    const 四 = batch.章列表.find((r) => r.章号 === 4)
+    assert.equal(三.状态, 章状态.打回, '空目录须可被 stage-chapter 覆盖恢复')
+    assert.equal(四.状态, 章状态.受影响)
+  } finally {
+    await cleanup()
+  }
+})
+
+// D5:名义只读路径传 heal:false——批次.json 缺失只重建内存视图,不落盘
+test('readBatch heal=false:批次.json 缺失时不落盘自愈,默认路径才写', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles({ 批次大小: 8 }))
+  try {
+    assert.equal((await stage(ctx, 3)).ok, true)
+    const metaPath = path.join(ctx.repoPath, '工作区', '待定稿', '批次.json')
+    await fs.rm(metaPath, { force: true })
+
+    const cold = await readBatch(ctx.repoPath, { heal: false })
+    assert.equal(cold.exists, true)
+    await assert.rejects(() => fs.access(metaPath), 'heal=false 不得写盘')
+
+    const healed = await readBatch(ctx.repoPath)
+    assert.equal(healed.exists, true)
+    await fs.access(metaPath)
+  } finally {
+    await cleanup()
+  }
+})

+ 6 - 0
v7/test/state-machine/auto-mode.test.js

@@ -4,6 +4,7 @@ import path from 'node:path'
 import { promises as fs } from 'node:fs'
 import { determineNextState } from '../../src/state-machine/index.js'
 import { stageChapter, rejectFrom } from '../../src/staging/index.js'
+import { createGit } from '../../src/finalize/git.js'
 import { gitBookCtx } from '../commands/_helper.js'
 
 // 开关矩阵(PRD #14):自动确认细纲 × 连写。全关=既有回归(router.test.js 全量);
@@ -20,6 +21,11 @@ async function setBookYaml(ctx, key, value) {
   const line = `${key}: ${value}`
   const re = new RegExp(`^${key}:.*$`, 'm')
   await fs.writeFile(p, re.test(src) ? src.replace(re, line) : `${src}${line}\n`, 'utf8')
+  // book.yaml 在跟踪面内(决策 D6),不补登会被序 2 拦——模拟 relink 已完成
+  const git = createGit(ctx.repoPath)
+  await git.ensureIdentity()
+  await git.add(['book.yaml'])
+  await git.commit(`fix(手改): 配置 ${key}`)
 }
 
 async function stage3(ctx) {

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

@@ -267,3 +267,64 @@ test('命中即停:手改(序2) + 工作区草稿(序3) 同时存在 → 先
     await cleanup()
   }
 })
+
+// 决策 D6:跟踪面扩为 定稿/大纲/文风/book.yaml——文风铁律/book.yaml 的修复回写
+// 由序 2 补登入档,不再长期漂在未提交区(随后 goto reset 会抹掉)
+test('序2 跟踪面(D3 区):文风铁律与 book.yaml 手改都被检出待补登', async () => {
+  const { ctx, root, cleanup } = await makeGitBook(
+    healthyBook({ '文风/文风铁律.md': '---\n条数: 1\n---\n- 短句为主。\n' })
+  )
+  try {
+    await fs.writeFile(path.join(root, '文风/文风铁律.md'), '---\n条数: 2\n---\n- 短句为主。\n- 忌堆形容词。\n', 'utf8')
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 2, JSON.stringify(r))
+    assert.deepEqual(r.dto.变更文件, ['文风/文风铁律.md'])
+
+    await ctx.cache.query('SELECT 1') // 确保缓存活着(下一步复用 ctx)
+    // 追加新键(healthyBook 已有 体检周期,重复键会触发序0 解析失败而非序2)
+    await fs.appendFile(path.join(root, 'book.yaml'), '每章目标字数: 2500\n', 'utf8')
+    const r2 = await determineNextState(ctx)
+    assert.equal(r2.序, 2)
+    assert.ok(r2.dto.变更文件.includes('book.yaml'), JSON.stringify(r2.dto.变更文件))
+  } finally {
+    await cleanup()
+  }
+})
+
+// D6:待定稿目录里只有杂项文件(无章目录、无有效 meta)≠ 批次——
+// 现存判定与 readBatch.exists 同口径,不出「续跑」却无批次可跑的自相矛盾 DTO
+test('序3 口径(D6):待定稿/ 只有杂项文件不算批次,不触发批次续跑', async () => {
+  const { ctx, root, cleanup } = await makeGitBook(healthyBook())
+  try {
+    await fs.mkdir(path.join(root, '工作区/待定稿'), { recursive: true })
+    await fs.writeFile(path.join(root, '工作区/待定稿/notes.txt'), '杂项', 'utf8')
+    const r = await determineNextState(ctx)
+    assert.notEqual(r.序, 3, JSON.stringify(r))
+    assert.equal(r.序, 6)
+  } finally {
+    await cleanup()
+  }
+})
+
+// D7:章号查询失败不裸抛、也不降级成「第 1 章」引导重抄——人话报错 + 自愈指引
+test('序4-6 前章号查询失败 → ok:false 人话(不裸抛、不误导起草第 1 章)', async () => {
+  const { ctx, cleanup } = await makeGitBook(healthyBook())
+  try {
+    const broken = {
+      repoPath: ctx.repoPath,
+      cache: {
+        query: async (sql) => {
+          if (sql.includes('FROM chapters ORDER BY')) throw new Error('database disk image is malformed')
+          return ctx.cache.query(sql)
+        },
+      },
+    }
+    const r = await determineNextState(broken)
+    assert.equal(r.ok, false)
+    assert.equal(r.state, 'cache-error')
+    assert.match(r.message, /缓存查询失败/)
+    assert.match(r.message, /\.cache/)
+  } finally {
+    await cleanup()
+  }
+})

+ 21 - 9
v7/test/storage/adapters/ChapterReader.test.js

@@ -53,13 +53,25 @@ test('readHead:读取章节开头 N 字', async () => {
   assert.ok(result.text.includes('林晚抬头'))
 })
 
-test('readRange:批量读取章号范围', async () => {
-  const reader = new ChapterReader(fixtureRoot)
-  const result = await reader.readRange(1, 2, ['标题', '卷'])
-
-  assert.equal(result.ok, true)
-  assert.equal(result.chapters.length, 2)
-  assert.equal(result.chapters[0].章号, 1)
-  assert.equal(result.chapters[0].标题, '开局')
-  assert.equal(result.chapters[1].标题, '初遇')
+// A6:接口形状不随缓存冷热漂移——缓存命中与文件降级都输出中文键
+test('readFrontMatter:缓存命中与文件降级输出同一形状(中文键)', async () => {
+  const fakeCache = {
+    query: async () => [
+      {
+        chapter_num: 1, title: '开局', volume_num: 1, perspective: '林晚',
+        story_time: null, word_count: 100, chapter_position: '开篇',
+        hook_type: '悬念钩-强', mood_position: null, is_volume_end: 0,
+        file_path: '/x/0001.md', is_key_chapter: 0,
+      },
+    ],
+  }
+  const warm = await new ChapterReader(fixtureRoot, fakeCache).readFrontMatter(1)
+  const cold = await new ChapterReader(fixtureRoot).readFrontMatter(1)
+  assert.equal(warm.ok, true)
+  assert.equal(cold.ok, true)
+  assert.equal(warm.data.章号, 1)
+  assert.equal(warm.data.标题, '开局')
+  assert.equal(warm.data.chapter_num, undefined, '缓存命中不得泄漏英文列名')
+  assert.equal(warm.data.file_path, undefined, '不得泄漏文件路径')
+  assert.equal(cold.data.标题, '开局')
 })

+ 2 - 2
v7/test/storage/parsers/yaml-safe.test.js

@@ -87,8 +87,8 @@ test('容错读取保留未知字段:解析→修改→序列化', async () =>
   // 修改已知字段
   parsed.data.标题 = '新标题'
 
-  // 写回(保留未知字段)
-  const serialized = serializeFrontMatter(parsed.data, parsed.body, parsed.rawYAML)
+  // 写回(未知字段的保留由 data 展开达成——parseFrontMatter 把全部键放进 data
+  const serialized = serializeFrontMatter(parsed.data, parsed.body)
 
   assert.ok(serialized.includes('自定义字段: 自定义值'), '未知字段应被保留')
   assert.ok(serialized.includes('标题: 新标题'), '已知字段应被更新')