Procházet zdrojové kódy

docs(v7): M1-M7 第三轮全量 review——七区并行精读+真实路径探针,P1×12/P2×18/S×10

- 七只读子代理分区精读(research/review-A..G.md,~60 候选带 file:line)
- 主会话探针裁决(probe-m1-m7.mjs + followup):全走 persistCreateBook 真建书
- CONFIRMED P1×11:goto×批次断档、手动finalize×批次双计卡死、persistRepair 锁死序0、
  finalize 清理目录已 commit 误报失败、迁移角色中英类型断裂不可见、别名分隔三源分裂、
  yaml-dialect 漏引/漏转义反斜杠、book.yaml 手写拼接、一对多别名迁移硬回滚、表格管道不转义
- REFUTED×4 记录防复审(B1 重抄链被序0/序2 兜住等);历轮修复全在位;429 测试绿零代码改动
- 修复 backlog 三批建议待裁决
lingfengQAQ před 11 hodinami
rodič
revize
08983c462d

+ 1 - 0
.trellis/tasks/07-05-m1-m7-review/check.jsonl

@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

+ 1 - 0
.trellis/tasks/07-05-m1-m7-review/implement.jsonl

@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

+ 36 - 0
.trellis/tasks/07-05-m1-m7-review/prd.md

@@ -0,0 +1,36 @@
+# M1-M7 全量 review(M6/M7 落地后的第三轮通审)
+
+## Goal
+
+M6(自动模式)/M7(导出与迁移)落地后,对 v7 全部代码(110 源文件)做第三轮全量 review。前两轮定性结论:**流程自身绿、流程间接力没人测**(M1-M4 轮抓到"主循环跑不通"被测试脚手架掩盖;M1-M5 轮抓到三条支路成环缺口)。本轮重点 = M6/M7 新代码 + 新旧流程交叉接缝 + 历轮修复仍在位复查。**本任务只审不修**:产出验证过的发现清单与修复 backlog,修复经用户裁决后另行执行。
+
+## 方式(用户暂离按推荐默认,可推翻)
+
+- 并行找 + 探针核:7 个只读子代理分区精读(trellis-research,产出落 research/),主会话汇总去重后**真 CLI/库探针逐个复现**——复现了才 CONFIRMED,复现不了降 PLAUSIBLE 或剔除(M5 轮 review-probe.mjs 先例)。
+- 分区:A 存储层(adapters/parsers/serializers/util);B 缓存与统计(cache/style-stats/health-check);C 写章流程(prep/mechanical-check/review/finalize/dto);D 状态机与外环(state-machine/session/runtime);E M6 staging 与三消费点叠加 + 批次×手动流程交叉;F M7 export/migrate 与建书/书单接力;G 命令壳/bin/installer/host-shells + 测试脚手架掩盖审计。
+
+## 历史 bug 模式(子代理"闻味"清单)
+
+1. 空壳/占位实现(M1 教训);2. 流程接力断裂——A 流程完成后 B 流程判定错(不 commit 误触序2、改源不刷缓存、无执行通道);3. 测试脚手架掩盖真实路径(手动 git init/rebuild);4. 工件清理静默漏(workspaceFiles 前缀);5. 全局 ignore/路径规则吞文件(两次踩);6. Windows 中文路径/编码;7. 双源漂移(SKILL vs 实现、常量双写);8. 回滚边界过宽/过窄;9. 缓存当真源读、staged 数据入缓存/指纹;10. 作者界面域混机器味(英文/堆栈)。
+
+## 本轮新增关注(M6/M7 特有接缝)
+
+- 批次进行中 × 手动例外流程交叉:stage 后 goto-chapter / relink / 体检 / 卷复盘 / 手动 finalize 单章会怎样?
+- finalize-batch 复用 finalizeChapter 的 workspaceFiles 语义;threadCreates 手动/批次两路径一致性
+- migrate 产物再进主循环全程(migrate→next→细纲→备料→机检→两审→定稿→再 next)
+- export 对批次章/工作区草稿的边界;migrate 与既有书名/books.jsonl 冲突面
+- M5.5 统计确定性在 M6/M7 改动后仍守(fingerprints/imagery_top 无 staged/迁移污染)
+
+## Acceptance Criteria
+
+- [x] AC1 七区 research 报告齐(research/review-{A..G}.md,每条候选带 file:line + 怀疑理由 + 建议探针)
+- [x] AC2 候选全部过探针裁决:CONFIRMED 11 P1(probe-m1-m7.mjs 7 探针 + probe-followup.mjs 2 补充 + 子代理现场复现 A×6/F×4)/ PLAUSIBLE 1(R12 需故障注入,逻辑确凿)/ REFUTED 4(记录在报告防复审)
+- [x] AC3 汇总报告 `review-m1-m7.md` 落任务目录:P0×0 / P1×12 / P2×18 / S×10 分层 + 三批修复 backlog
+- [x] AC4 历轮修复在位复查:M1-M4 P0/P1、M1-M5 三条 P1(主会话 grep 与 D 区双验证)、M5.5 确定性、M6 不变量——全在位
+- [x] AC5 全量测试仍绿(429 pass 0 fail;v7/ 零改动,探针脚本落任务目录)
+
+## Out of Scope
+
+- ❌ 修复实施(另行任务,按本轮 backlog)
+- ❌ 性能优化建议(除非构成正确性问题)
+- ❌ v6 遗产代码(webnovel-writer/ 冻结)

+ 65 - 0
.trellis/tasks/07-05-m1-m7-review/probe-followup.mjs

@@ -0,0 +1,65 @@
+// 补充探针:P-4b 重号章文件(序 0 拦不住的静默截断)、P-7b finalize 清理目录(正确相对路径)
+import { promises as fs } from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const V7 = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'v7')
+const m = (p) => import(`file:///${path.join(V7, p).replace(/\\/g, '/')}`)
+
+const { persistCreateBook } = await m('src/state-machine/persist.js')
+const { finalizeChapter } = await m('src/finalize/index.js')
+const { determineNextState } = await m('src/state-machine/index.js')
+const { CacheManager } = await m('src/cache/index.js')
+const { createGit } = await m('src/finalize/git.js')
+
+const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-probe2-'))
+let n = 0
+async function makeBook() {
+  const repo = path.join(tmpRoot, `book-${++n}`)
+  await fs.mkdir(repo, { recursive: true })
+  const r = await persistCreateBook({ repoPath: repo, cache: null }, {
+    book: { spec_version: '7.0', 书名: `补探${n}`, 类型: '玄幻', 每章目标字数: 3000, 卷规模: 40 },
+    总纲: '# 总纲\n\n## 结局\nx\n', 卷纲: '# 第1卷\ny\n',
+  })
+  if (!r.ok) throw new Error(r.error)
+  const cache = new CacheManager(path.join(repo, '.cache', 'index.db'))
+  await cache.ensureReady(repo)
+  return { repoPath: repo, cache }
+}
+const payloadOf = (num, extra = {}) => ({
+  chapterNum: num,
+  frontMatter: { 章号: num, 标题: `第${num}章题`, 卷: 1, 书内时间: `夏月初${num}`, 字数: 100, 章定位: '推进', 钩子: '危机钩-强', 情绪定位: '铺垫' },
+  body: `第${num}章正文。`, summary: `摘${num}`, commitLines: {}, workspaceFiles: [], ...extra,
+})
+
+// P-4b: 同章号两个合法文件 → 序 0 不拦 → INSERT 撞主键被吞 → MAX 偏小 → next 重抄
+try {
+  const ctx = await makeBook()
+  for (const c of [1, 2, 3]) await finalizeChapter(ctx, payloadOf(c))
+  const chDir = path.join(ctx.repoPath, '定稿', '正文')
+  // 手改场景:作者复制了一份第 3 章改标题(两文件 front matter 均合法、同章号)
+  const f3 = (await fs.readdir(chDir)).find((f) => f.startsWith('0003-'))
+  await fs.copyFile(path.join(chDir, f3), path.join(chDir, '0003-旧稿备份.md'))
+  const rb = await ctx.cache.rebuildFromSource(ctx.repoPath)
+  const max = (await ctx.cache.query('SELECT MAX(chapter_num) AS m FROM chapters', []))[0].m
+  const next = await determineNextState(ctx)
+  console.log(`P-4b 重号章: rebuild.ok=${rb.ok} warnings=${JSON.stringify(rb.warnings || [])} MAX=${max} next序=${next.序} nextChapter=${next.dto?.nextChapter}`)
+  console.log(max === 2 && next.dto?.nextChapter === 3 ? '【CONFIRMED】静默丢章+指向重抄已存在的第3章' : `【部分/REFUTED】看上行`)
+  await ctx.cache.close()
+} catch (e) { console.log(`P-4b 异常: ${e.message}`) }
+
+// P-7b: workspaceFiles 含目录(正确相对路径)→ rm 无 recursive
+try {
+  const ctx = await makeBook()
+  await fs.mkdir(path.join(ctx.repoPath, '工作区', '评审报告'), { recursive: true })
+  await fs.writeFile(path.join(ctx.repoPath, '工作区', '评审报告', 'a.md'), 'x', 'utf8')
+  const r = await finalizeChapter(ctx, payloadOf(1, { workspaceFiles: ['评审报告'] }))
+  const committed = await createGit(ctx.repoPath).findChapterCommit(1)
+  const dirLeft = await fs.access(path.join(ctx.repoPath, '工作区', '评审报告')).then(() => true, () => false)
+  console.log(`P-7b 清理目录: finalize.ok=${r.ok} err=${(r.error || '').slice(0, 60)} ch(1)commit=${committed ? '在' : '无'} 目录残留=${dirLeft}`)
+  console.log(!r.ok && committed ? '【CONFIRMED】已 commit 却报失败(C1 成立)' : r.ok && dirLeft ? '【部分】ok 但目录静默残留(清理漏,弱化版 C1)' : '【REFUTED】')
+  await ctx.cache.close()
+} catch (e) { console.log(`P-7b 异常: ${e.message}`) }
+
+await fs.rm(tmpRoot, { recursive: true, force: true })

+ 185 - 0
.trellis/tasks/07-05-m1-m7-review/probe-m1-m7.mjs

@@ -0,0 +1,185 @@
+// M1-M7 review 行为探针:库级直调真实路径(persistCreateBook 建书,非测试脚手架),
+// 逐条裁决候选 CONFIRMED / REFUTED。跑完自删临时目录。
+import { promises as fs } from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const V7 = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'v7')
+const m = (p) => import(`file:///${path.join(V7, p).replace(/\\/g, '/')}`)
+
+const { persistCreateBook, persistRepair } = await m('src/state-machine/persist.js')
+const { finalizeChapter } = await m('src/finalize/index.js')
+const { stageChapter, finalizeBatch, readBatch, stagedFacts, overlayBookStatus } = await m('src/staging/index.js')
+const { gotoChapter } = await m('src/state-machine/flows/goto-chapter.js')
+const { determineNextState } = await m('src/state-machine/index.js')
+const { CacheManager } = await m('src/cache/index.js')
+const { assembleBookStatus } = await m('src/prep/book-status.js')
+const { EntityReader } = await m('src/storage/adapters/EntityReader.js')
+const { migrateV6 } = await m('src/migrate/index.js')
+const { run: listCharacters } = await m('src/commands/list-characters.js')
+
+const verdicts = []
+const V = (id, confirmed, detail) => {
+  verdicts.push({ id, confirmed, detail })
+  console.log(`${confirmed ? '【CONFIRMED】' : '【REFUTED】'} ${id}: ${detail}`)
+}
+
+const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-review-probe-'))
+let n = 0
+
+/** 真实建书:persistCreateBook + 独立 cache(不用 gitBookCtx——G-1 指其形态失真)。 */
+async function makeBook(extraYaml = '') {
+  const repo = path.join(tmpRoot, `book-${++n}`)
+  await fs.mkdir(repo, { recursive: true })
+  const ctx0 = { repoPath: repo, cache: null }
+  const r = await persistCreateBook(ctx0, {
+    book: { spec_version: '7.0', 书名: `探针${n}`, 类型: '玄幻', 每章目标字数: 3000, 卷规模: 40 },
+    总纲: '# 总纲\n\n## 结局\n主角胜。\n',
+    卷纲: '# 第1卷\n\n第一卷纲要。\n',
+  })
+  if (!r.ok) throw new Error(`建书失败: ${r.error}`)
+  if (extraYaml) {
+    const p = path.join(repo, 'book.yaml')
+    await fs.writeFile(p, (await fs.readFile(p, 'utf8')) + extraYaml, 'utf8')
+  }
+  const cache = new CacheManager(path.join(repo, '.cache', 'index.db'))
+  await cache.ensureReady(repo)
+  return { repoPath: repo, cache }
+}
+
+const payloadOf = (num, extra = {}) => ({
+  chapterNum: num,
+  frontMatter: {
+    章号: num, 标题: `第${num}章题`, 卷: 1, 书内时间: `夏月初${num}`, 字数: 100,
+    章定位: '推进', 钩子: '危机钩-强', 情绪定位: '铺垫',
+  },
+  body: `第${num}章正文内容,情节推进一格。`,
+  summary: `第${num}章摘要。`,
+  commitLines: {},
+  workspaceFiles: [],
+  ...extra,
+})
+
+async function stage(ctx, num, extra = {}) {
+  await fs.mkdir(path.join(ctx.repoPath, '工作区'), { recursive: true })
+  await fs.writeFile(path.join(ctx.repoPath, '工作区', '审稿.md'), `# 第 ${num} 章审稿单\n\n> 共 0 个问题:0 阻断。\n`, 'utf8')
+  const r = await stageChapter(ctx, { chapterNum: num, payload: payloadOf(num, extra) })
+  if (!r.ok) throw new Error(`stage ${num} 失败: ${r.error}`)
+}
+
+// ============ P-1 (E1/D2): goto 回退 × 进行中批次 → 孤儿批次 + 定稿章号缺口 ============
+try {
+  const ctx = await makeBook()
+  for (const c of [1, 2, 3]) {
+    const r = await finalizeChapter(ctx, payloadOf(c))
+    if (!r.ok) throw new Error(`定稿${c}: ${r.error}`)
+  }
+  await stage(ctx, 4)
+  const g = await gotoChapter(ctx, { chapterNum: 2, confirm: true })
+  const batch = await readBatch(ctx.repoPath)
+  const next = await determineNextState(ctx)
+  const fb = await finalizeBatch(ctx)
+  const files = (await fs.readdir(path.join(ctx.repoPath, '定稿', '正文'))).sort()
+  const nums = files.map((f) => Number(f.slice(0, 4)))
+  const hasGap = fb.ok && nums.includes(4) && !nums.includes(3)
+  V('P-1 goto×批次(E1)', g.ok && batch.exists && hasGap,
+    `goto2=${g.ok} 批次残留=${batch.exists} next序=${next.序} finalizeBatch.ok=${fb.ok} 定稿=[${nums}]${hasGap ? ' ← 缺第3章,断档确认' : ''}`)
+  await ctx.cache.close()
+} catch (e) { V('P-1 goto×批次(E1)', false, `探针异常: ${e.message}`) }
+
+// ============ P-2 (E2): 批次进行中手动 finalize 同章 → 双计 + finalize-batch 卡死 ============
+try {
+  const ctx = await makeBook()
+  await finalizeChapter(ctx, payloadOf(1))
+  await stage(ctx, 2, { threadCreates: [{ id: '伏笔-201', 短题: '试线', frontMatter: { 强度: '中', 状态: '进行', 开启章: 2 }, body: '## 描述\n试。\n\n## 收尾计划\n第9章。\n\n## 履历\n- 第2章:埋下\n' }] })
+  const manual = await finalizeChapter(ctx, payloadOf(2, { threadCreates: [{ id: '伏笔-201', 短题: '试线', frontMatter: { 强度: '中', 状态: '进行', 开启章: 2 }, body: '## 描述\n试。\n\n## 收尾计划\n第9章。\n\n## 履历\n- 第2章:埋下\n' }] }))
+  const status = await assembleBookStatus(ctx)
+  const facts = await stagedFacts(ctx.repoPath)
+  const overlaid = overlayBookStatus(status.data ?? status, facts)
+  const 总章数 = overlaid?.总章数 ?? overlaid?.data?.总章数
+  const fb = await finalizeBatch(ctx)
+  V('P-2 手动finalize×批次(E2)', manual.ok && !fb.ok,
+    `手动finalize2.ok=${manual.ok} overlay总章数=${总章数}(定稿2+staged1=双计) finalizeBatch.ok=${fb.ok} err=${(fb.error || '').slice(0, 60)}`)
+  await ctx.cache.close()
+} catch (e) { V('P-2 手动finalize×批次(E2)', false, `探针异常: ${e.message}`) }
+
+// ============ P-3 (D1): persistRepair 修 book.yaml 被 front matter 校验拒绝 ============
+try {
+  const ctx = await makeBook('卷规模: 40\n') // 追加重复键 → YAML 解析失败
+  const next = await determineNextState(ctx)
+  const failures = next.dto?.failures?.map((f) => f.file) || []
+  const good = 'spec_version: "7.0"\n书名: 探针修复\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n'
+  const rep = await persistRepair(ctx, { repairs: [{ file: 'book.yaml', content: good }] }, { allowedFiles: failures })
+  V('P-3 persistRepair锁死(D1)', next.序 === 0 && failures.includes('book.yaml') && !rep.ok,
+    `序=${next.序} failures=[${failures}] 合法修复被拒=${!rep.ok} err=${(rep.error || '').slice(0, 60)}`)
+  await ctx.cache.close()
+} catch (e) { V('P-3 persistRepair锁死(D1)', false, `探针异常: ${e.message}`) }
+
+// ============ P-4 (B1): 坏章 rebuild 静默截断 → MAX 偏小 → next 重抄本章 ============
+try {
+  const ctx = await makeBook()
+  for (const c of [1, 2, 3]) await finalizeChapter(ctx, payloadOf(c))
+  const chDir = path.join(ctx.repoPath, '定稿', '正文')
+  const f3 = (await fs.readdir(chDir)).find((f) => f.startsWith('0003-'))
+  await fs.writeFile(path.join(chDir, f3), '---\n章号: [broken\n---\n正文', 'utf8')
+  const rb = await ctx.cache.rebuildFromSource(ctx.repoPath)
+  const max = (await ctx.cache.query('SELECT MAX(chapter_num) AS m FROM chapters', []))[0].m
+  const next = await determineNextState(ctx)
+  V('P-4 rebuild静默截断(B1)', rb.ok !== false && !(rb.warnings || []).length && max === 2 && next.dto?.nextChapter === 3,
+    `rebuild.ok=${rb.ok} warnings=${(rb.warnings || []).length} MAX=${max} next序=${next.序} nextChapter=${next.dto?.nextChapter}(=3 即重抄已存在第3章)`)
+  await ctx.cache.close()
+} catch (e) { V('P-4 rebuild静默截断(B1)', false, `探针异常: ${e.message}`) }
+
+// ============ P-5 (G2): migrate 名册中文类型 → 迁移书角色全不可见 ============
+try {
+  const workdir = path.join(tmpRoot, 'workdir-g2')
+  await fs.mkdir(path.join(workdir, '.webnovel'), { recursive: true })
+  const { tempV6Sqlite } = await m('test/migrate/_v6.js')
+  const v6 = await tempV6Sqlite()
+  const mig = await migrateV6({ workdir }, v6.v6Path)
+  const repo = path.join(workdir, '潮汐之下')
+  const cache = new CacheManager(path.join(repo, '.cache', 'index.db'))
+  await cache.ensureReady(repo)
+  const ctx = { repoPath: repo, cache }
+  const lc = await listCharacters([], {}, ctx)
+  const rows = await cache.query("SELECT id, type FROM entities", [])
+  const invisible = lc.ok && !(lc.output || '').includes('江遥')
+  V('P-5 迁移角色不可见(G2)', mig.ok && invisible,
+    `migrate.ok=${mig.ok} list-characters含江遥=${!invisible} entities.type=[${rows.map((r) => r.type).join(',')}]`)
+  await cache.close()
+  await v6.cleanup()
+} catch (e) { V('P-5 迁移角色不可见(G2)', false, `探针异常: ${e.message}`) }
+
+// ============ P-6 (A5 抽查): 名册中文分隔别名 → resolveAlias 全 MISS ============
+try {
+  const ctx = await makeBook()
+  await fs.mkdir(path.join(ctx.repoPath, '定稿', '设定'), { recursive: true })
+  await fs.writeFile(path.join(ctx.repoPath, '定稿', '设定', '名册.md'),
+    '| 正名 | 别名 | 类型 | 首现章 |\n|---|---|---|---|\n| 林晚 | 阿晚,晚儿、小晚 | character | 1 |\n', 'utf8')
+  await ctx.cache.rebuildFromSource(ctx.repoPath)
+  const er = new EntityReader(ctx.repoPath, ctx.cache)
+  const hits = []
+  for (const a of ['阿晚', '晚儿', '小晚']) hits.push((await er.resolveAlias(a)).ok)
+  V('P-6 别名中文分隔(A5)', hits.every((h) => !h), `resolveAlias(阿晚/晚儿/小晚)=[${hits}](全 false 即全 MISS)`)
+  await ctx.cache.close()
+} catch (e) { V('P-6 别名中文分隔(A5)', false, `探针异常: ${e.message}`) }
+
+// ============ P-7 (C1): finalize workspaceFiles 含目录 → 已 commit 却误报失败 ============
+try {
+  const ctx = await makeBook()
+  await fs.mkdir(path.join(ctx.repoPath, '工作区', '评审报告'), { recursive: true })
+  await fs.writeFile(path.join(ctx.repoPath, '工作区', '评审报告', 'a.md'), 'x', 'utf8')
+  const r = await finalizeChapter(ctx, payloadOf(1, { workspaceFiles: ['工作区/评审报告'] }))
+  const { createGit } = await m('src/finalize/git.js')
+  const git = createGit(ctx.repoPath)
+  const committed = await git.findChapterCommit(1)
+  V('P-7 finalize误报失败(C1)', !r.ok && !!committed,
+    `finalize.ok=${r.ok} err=${(r.error || '').slice(0, 50)} 但 ch(1) commit=${committed ? '已存在' : '无'}`)
+  await ctx.cache.close()
+} catch (e) { V('P-7 finalize误报失败(C1)', false, `探针异常: ${e.message}`) }
+
+// ============ 汇总 ============
+console.log('\n===== 裁决汇总 =====')
+for (const v of verdicts) console.log(`${v.confirmed ? 'CONFIRMED' : 'REFUTED  '} | ${v.id}`)
+await fs.rm(tmpRoot, { recursive: true, force: true })

+ 126 - 0
.trellis/tasks/07-05-m1-m7-review/research/review-A.md

@@ -0,0 +1,126 @@
+# Review A 区(存储层)候选清单
+
+- **分区**:`v7/src/storage/{adapters,parsers,serializers,atomic.js}` + `v7/src/util/`
+- **日期**:2026-07-05
+- **审查员**:Research Agent A(只审不修)
+- **格式**:`[severity file:line 一句话问题 | 怀疑理由 | 建议探针 | 置信]`
+- **已现场探针**:yaml 方言往返、markdown 表格往返、别名分隔、_findThreadFile 前缀命中、js-yaml 类型强转(脚本未落盘,见各条"探针已跑")。
+
+统计:**P1 × 6、P2 × 7、S × 4,共 17 条**。其中 6 条已现场复现(CONFIRMED),余为审读推断待主会话探针。
+
+---
+
+## P1(数据丢 / 整文件读失败 / 作者界面漂移)
+
+### 1. [P1] `serializers/yaml-dialect.js:79-121` `needsQuoting` 漏判大量会被 YAML 误判类型的字符串 → 往返损坏
+- **怀疑理由**:§4.3 要求"凡可能被 YAML 误判类型的字符串值必须加引号"。现仅覆盖 纯数字/浮点/true-false/null/含冒号/#开头/-开头/换行。漏:空串、前后空格、`0x1F`(hex)、`1e3`(科学计数)、`+5`、`~`、`.inf/.nan`、前导 YAML 指示符 `* & [ { ! ? | > @ %` 反引号 单双引号 逗号。
+- **探针已跑(真项目模块 serialize→parse)**:`""→null`、`"  甲  "→"甲"`、`"0x1F"→31`、`"1e3"→1000`、`"+5"→5`、`"~"→null` 全部 DRIFT。空串→null 与空格被裁最普遍(可选 front matter 字段留空即变 null)。
+- **建议探针**:对每个可选章/卡字段写空串或前后带空格值,`writeChapter`→`readFrontMatter` 比对;断言 `data.某字段===''`。
+- **置信**:高(已复现)。
+
+### 2. [P1] `serializers/yaml-dialect.js:65-71` 双引号分支只转义 `"` 不转义反斜杠 → 触发引号的值含 `\` 时整份 front matter 解析失败
+- **怀疑理由**:`value.replace(/"/g,'\\"')` 未处理 `\`。当值同时触发加引号(如含冒号)且含反斜杠(Windows 路径/注释),产出 `"C:\Users\x"`,js-yaml 见 `\U` 当非法转义 → 抛错 → `parseFrontMatter` 整文件 `ok:false`,适配器返回"解析失败"。`\ `(反斜杠+空格)则被静默吞掉。
+- **探针已跑**:`"C:\Users\x"` → 序列化 `f: "C:\Users\x"` → parse **PARSEFAIL**(expected hexadecimal character)。同类:值以 `*ptr`/`&a`/`[TBD` 开头 → 分别 PARSEFAIL/→null。
+- **建议探针**:角色卡/条目某字段写 `路径:C:\a\b` 或以 `[` `*` 开头的短题,写盘后 `readCharacterFrontMatter` 断言 `ok`。
+- **置信**:高(已复现,属整文件读失败,比 #1 更重)。
+
+### 3. [P1] `atomic.js:27-51,60-66` 出错回滚会删掉尚未备份的原文件 → 覆盖写失败即丢原文
+- **怀疑理由**:`plan.existed` 仅在 `fs.rename(full→backup)` 成功后(line 33)才置 true。若 `writeFile(tmp)`(line 27) 或 `rename(full→backup)`(line 32) 先失败,`existed` 仍为 false,`restorePlan`(line 64) 走 `fs.rm(plan.final)` 把**从未动过的原文件**删掉。多文件批次里靠后文件失败时,其原文无备份即被删。Windows 触发面:`full+.wnwtmp.pid.n` 超 MAX_PATH 260 → tmp 写 ENAMETOOLONG;或原文件被编辑器/杀软占用 → rename EPERM。消费方 `state-machine/persist.js:100/129`、`review/index.js:254` 会覆盖既有 大纲/细纲 源文件。
+- **建议探针**:mock `fs.writeFile` 对第 2 个文件抛错,`writeAtomicBatch(repo,[已存在A,已存在B])`,断言 B 原文仍在;或构造接近 260 字符的 repoPath 触发 tmp 超长。
+- **置信**:中(逻辑清晰,需故障注入复现;与 bug 模式 #6/#8 吻合)。
+
+### 4. [P1] `serializers/markdown-table.js:10` 序列化不转义单元格内 `|` 与换行 → 名册/时间线单元格数据丢失
+- **怀疑理由**:`String(r[h]??'')` 直接拼进 `| ... |`。值含 `|` 会在读回时被 `split('|')` 切成多格 → 列数不匹配 → 补齐/截断 → 尾部内容丢。含 `\n` 则整表错行。
+- **探针已跑**:别名 `"刀疤|老王"` 序列化 `| 甲 | 刀疤|老王 |` → 读回 `别名:"刀疤"`("老王"丢)。
+- **建议探针**:`upsertRosterRow({正名:'甲',别名:'刀疤|老王'})` → `resolveAlias('老王')` 断言命中;应 MISS 复现。
+- **置信**:高(已复现)。
+
+### 5. [P1] `adapters/EntityReader.js:87` + `cache/rebuilder.js:262` 别名按 ASCII `,` 拆,`staging/index.js:220` 按 `[,,、]` 拆 → 三源分裂,中文分隔的别名解析失败
+- **怀疑理由**:中文作者惯用全角逗号 `,` 或顿号 `、` 分隔别名。EntityReader(文件降级)与 rebuilder(entity_aliases 真源)只切 ASCII 逗号 → 整串当一个别名;resolveAlias MISS、别名唯一性校验漏冲突;而 staging `splitAliases` 三种都切 → 叠加视图与真源对不上(bug 模式 #7 双源 + #6 中文)。migrate 写 `join(', ')`(transform.js:117) 是 ASCII,迁移书暂安全;手改/AI 写的名册中招。
+- **探针已跑**:名册 `别名: 阿晚,晚儿、小晚` → `resolveAlias('阿晚'/'晚儿'/'小晚')` 全部 MISS。
+- **建议探针**:同上;再删缓存重建后查 `entity_aliases` 表,断言只有 1 行 bogus 别名。
+- **置信**:高(已复现)。B 区 rebuilder 同病,接缝一并记。
+
+### 6. [P1] `adapters/ChapterReader.js:19-32` 与 `ThreadLedgerReader.js:19-33` 命中缓存返回英文列名、降级读文件返回中文键 → 精准读接口形状随缓存冷热漂移
+- **怀疑理由**:缓存命中 `return rows[0]`(`chapter_num/title/word_count`…英文 snake_case);文件降级 `return parsed.data`(`章号/标题/字数`…中文)。`read-chapter.js:15/19` 与 `read-thread.js:13/32` 都传 `ctx.cache` 且 `JSON.stringify(r.data)` → 作者/AI 看到的键名随缓存是否命中而变;暖缓存下输出机器味英文列(bug 模式 #10 + #7)。schema.js 列名与 fixture `0001-开局.md` 中文键已证形状不同;现有测试仅覆盖冷缓存路径(`ChapterReader.test.js` 无 cache 实参)。
+- **建议探针**:同一 repo,先 `readFrontMatter(1)` 无缓存记键集,再建缓存后重调,断言键集一致;预期出现 `chapter_num` vs `章号` 差异。
+- **置信**:高(漂移确凿)/ 中(对 AI 消费的实际危害取决于下游是否读中文键)。
+
+---
+
+## P2(支路错 / 稳健性 / 幂等)
+
+### 7. [P2] `parsers/markdown-table.js:29-36,100-105` 全角管道符 `|` 表格整表解析失败 → 名册/时间线静默变空
+- **怀疑理由**:只认 ASCII `|` 作围栏。作者用中文输入法打出 `| 正名 |…` 整行不以 ASCII `|` 起止 → 表头判失败 → `ok:false` → resolveAlias/TimelineReader 拿空。
+- **探针已跑**:`| 正名 | 别名 |\n|---|---|\n…` → `parseMarkdownTable.ok=false, rows=0`。
+- **建议探针**:写全角管道名册,`resolveAlias` 任意别名断言 MISS 且无报错日志。
+- **置信**:高(行为)/ 中(严重度:仅手改触发)。
+
+### 8. [P2] `parsers/markdown-table.js:67-79` 不识别 GFM `\|` 转义、且列数不符时静默截断多余列 → 作者额外列在改写时被丢
+- **怀疑理由**:`\|`(GFM 单元格内字面竖线)被当分隔切开;行 cell 多于表头时 `splice(headers.length)` 丢尾列。名册/时间线若被作者加了额外列(如 备注),writer 全表重写会丢。
+- **探针已跑**:`| 甲 | a\|b |` → 读回 `别名:"a"`(headers 仍 2)。
+- **建议探针**:给名册加第 5 列 `备注`,`upsertRosterRow` 改一行后读回,断言 备注 列还在(预期丢)。
+- **置信**:中。
+
+### 9. [P2] `adapters/ThreadLedgerReader.js:213`、`ThreadLedgerWriter.js:99`、`SecretReader.js:115` `startsWith(id)` 无界前缀命中 → 查不存在的短号命中长号条目
+- **怀疑理由**:`files.find(f=>f.startsWith(threadId))` 无尾分隔符。查 `伏笔-1`(无此文件)会命中 `伏笔-10-x.md` → 读/改到错条目,`appendHistory` 追到错文件。createThread 去重用 `${idStr}-`(有界,line 41)、_find 用无界(双源不一致 #7)。id 宽度未强制(createThread 只校验 `\S+-\d+`,migrate 补 3 位但 AI/手动可给非补零)。
+- **探针已跑**:建 `伏笔-1-断剑.md` 与 `伏笔-10-血书.md`,`_findThreadFile('伏笔-1')` 本次返回正确(因 readdir 序 `伏笔-1-` < `伏笔-10`);最坏例为**目标不存在、长号存在**时返回长号。
+- **建议探针**:只建 `伏笔-10-x.md`,`readBasicInfo('伏笔-1')` 断言应"不存在",预期错误命中 伏笔-10。
+- **置信**:中(机制确凿,危害依赖非补零 id 出现)。
+
+### 10. [P2] `adapters/EntityWriter.js:26` 与 `EntityReader.js:21` 角色卡文件名用未净化 `${name}.md`,`migrate/transform.js:142` 却用 `sanitizeFileName(e.name)` → 名字含可净化字符时 migrate 写的卡 updateCharacter/reader 找不到
+- **怀疑理由**:ChapterWriter/ThreadLedgerWriter 都过 `sanitizeFileName`,角色卡读写两处不过(问题 #4 同源)。名字含 Windows 非法字符或前后空格/多空格时,migrate 落盘在净化名、updateCharacter 按原名找 → 返回"角色不存在" → finalize `characterUpdates` 中断。且名字含 `:`/`?` 时 updateCharacter 直接写失败。
+- **建议探针**:`migrate` 一个含空格或 `:` 的角色名,随后 `updateCharacter(原名,…)` 断言 `ok`;预期 MISS。
+- **置信**:中(触发面窄但确有分裂)。
+
+### 11. [P2] `adapters/EntityWriter.js:58-60` upsert 名册用 `rows[idx]=row` 整行替换 → 丢该行作者额外列
+- **怀疑理由**:只带 `正名/别名/类型/首现章` 的新 `row` 覆盖旧整行对象;旧行的额外列(如 备注)随之丢,其它行保留 → 同表内不一致。
+- **建议探针**:名册某行带 备注 列,`upsertRosterRow` 同正名后读回该行,断言 备注 保留(预期丢)。
+- **置信**:中。
+
+### 12. [P2] `adapters/TimelineWriter.js:41` `appendRow` 恒追加不按 `章` 去重 → finalize 重跑/批次重处理同章 → 时间线重复行累积
+- **怀疑理由**:与 `EntityWriter.upsertRosterRow`(按正名去重)相反,无幂等。定稿失败重试或 goto/relink 后重定稿同章会叠行(bug 模式 #2)。
+- **建议探针**:同 `volumeNum` 同 `章` 调 `appendRow` 两次,读回断言 1 行(预期 2 行)。
+- **置信**:中。
+
+### 13. [P2] `adapters/EntityReader.js:87` `row.别名.split` 在名册缺 `别名` 列时对 undefined 调用 → 抛 JS TypeError 泄漏到作者域
+- **怀疑理由**:表头无 别名 列时 `row.别名` 为 undefined,`.split` 抛 `Cannot read properties of undefined`,被 catch 原样回传(line 95)→ 作者看到英文 JS 栈味错误(bug 模式 #10)。
+- **建议探针**:名册表头去掉 别名 列,`resolveAlias('x')` 看 error 文案是否为 JS 原生报错。
+- **置信**:中。
+
+---
+
+## S(spec 漂移 / 死代码 / 低危稳健性)
+
+### 14. [S] `serializers/front-matter.js:31-69` `extractUnknownFields` 为死代码(无人传第 3 参 `originalYAML`),内含 latent bug
+- **怀疑理由**:全部 `serializeFrontMatter(` 调用点(ChapterWriter/EntityWriter/ThreadLedgerWriter/SecretWriter/migrate/staging)都只传两参。§4.5"未知字段保留"实际靠 `{...parsed.data,...updates}` 展开达成(`parseFrontMatter` 把全部键放进 data)。此函数 line 48 `^([^:]+):` 还会把含冒号的已知列表项误判成未知键重复追加——但因死代码未触发。
+- **建议探针**:无(记为清理项/latent)。**注**:靠 data 展开保留意味着作者写的**嵌套映射**未知字段会让 `serializeYAML` 抛错(line 20-22)→ updateThread/updateCharacter 返回 ok:false,符合 §4.5"嵌套走修复确认"但错误文案是机器味。
+- **置信**:高(死代码确凿)。
+
+### 15. [S] `adapters/ChapterReader.js:127-144` `readRange` 无外部调用者,且默认 `fields=['摘要']` 既不在章 front matter 也不在 chapters 表 → 两条路径都返回空摘要
+- **怀疑理由**:全仓 `readRange(` 仅定义处与自身。摘要真源在 `定稿/摘要/章摘要/NNNN.md`(SummaryWriter),front matter 无 摘要 键(见 fixture)。即便有人调,命中缓存/降级都取不到摘要。
+- **建议探针**:无(死代码 + 契约错位,记清理/修契约)。
+- **置信**:中。
+
+### 16. [S] `parsers/book-config.js:20-22` `requiredFields`/`missingFields` 计算后从不使用 → 死校验;缺必需字段静默套默认
+- **怀疑理由**:line 21 算出 missingFields 后无分支消费,恒 `ok:true`。缺 书名 也返回默认"未命名"。若这是有意"永远给默认",则校验代码应删;若是漏接,则缺字段该报。
+- **建议探针**:无(读代码即证)。相关:line 43 `{...defaults,...data}` 会把全部默认灌进返回对象;当前无 BookConfigWriter 回写故不落盘,若日后加 writer 需防默认污染 book.yaml。
+- **置信**:高(死校验)/ 低(危害)。
+
+### 17. [S] `util/markdown.js:31-57`/`util/thread-declarations.js:33` 段匹配与声明解析偏脆
+- **怀疑理由**:`appendUnderSection` 用 `includes(sectionTitle)` 子串匹配 ## 段——两个标题都含关键词(如"履历"与"补充履历")会追到先出现的错段;`parseThreadDeclarations` 用 `^(\S+)\s+(\S+)$` 单空格两段式,动词或 id 含空格即判 malformed。均属低频。
+- **建议探针**:条目正文放两个含"履历"的 ##,`appendHistory` 看落点;低优先。
+- **置信**:低。
+
+---
+
+## 已核对无问题 / 无漂移(缩小复查面)
+- `EntityReader.resolveAlias` 返回值两路径一致(缓存 `entity_id` / 文件 `正名` 都是正名),无形状漂移;漂移只在 `readFrontMatter`/`readBasicInfo` 返回整行处(#6)。
+- `SecretReader.readBasicInfo` 不走缓存(只 `_findSecretFile` 读文件),无 #6 式中英漂移;但 `_findSecretFile` 有 #9 前缀命中问题。
+- `ChapterReader._findChapterFile`(line 156) 与 `ChapterWriter.backupOldChapterFiles`(line 24) 均用 4 位补零 + 尾 `-` 有界前缀,无 #9 式碰撞;章号文件名同源用 `sanitizeFileName`(ChapterWriter:69),与 staging 草稿标题取值同经 `parsed.data.标题`——章文件净化同源,仅角色卡不同源(#10)。
+- `2026-07-05`/`yes-no-on-off`/`010`/`1_000` 经 js-yaml 5.2.0 DEFAULT 仍为字符串(`010` 因 `^\d+$` 已被 needsQuoting 引住)——日期/YAML1.1 布尔**不**构成 #1 漏引项,已排除。
+
+## Caveats
+- 现场探针脚本用 `node --input-type=module -e` 即时跑,未落盘(遵守"只写 review-A.md");主会话复核可复用各条"探针已跑/建议探针"。
+- rebuilder.js/staging/index.js/finalize/index.js 属 B/C/E 区,仅在接缝处(别名分隔 #5、名册缺列致整次重建 ROLLBACK 见下)点到即止。
+- **接缝附记(B 区)**:`cache/rebuilder.js:262` 名册缺 别名 列时 `undefined.split` 抛错 → line 276 catch 返回 `ok:false` → 整次重建 ROLLBACK;违反 §5.2"名册格式问题应软跳过、只有别名冲突才硬错"。建议 B 区探针:名册去 别名 列后 `rebuildFromSource`,断言 chapters/threads 仍入库、仅 warning。

+ 79 - 0
.trellis/tasks/07-05-m1-m7-review/research/review-B.md

@@ -0,0 +1,79 @@
+# Review B 区:缓存与统计(cache / style-stats / health-check)
+
+- **审查范围**:`v7/src/cache/`(index/rebuilder/schema)、`v7/src/style-stats/index.js`、`v7/src/health-check/index.js`,及其跨缝:staging、migrate、finalize、persist、goto/retcon/relink
+- **依据**:`.trellis/spec/backend/database-guidelines.md`(§2.2/2.5/5.1/5.3/7.2)、PRD 历史 bug 闻味清单(尤其 #2 改源不刷、#9 缓存当真源/staged 入表)
+- **口径**:候选格式 `[severity] file:line 问题 | 怀疑理由 | 建议探针 | 置信`。severity:P0 主循环断/数据丢;P1 支路断/污染;P2 稳健性;S spec 漂移。宁可多报。
+- **Date**: 2026-07-05
+
+---
+
+## 焦点 1:删缓存重建一致 / 扫描面覆盖 / 单事务边界
+
+> 结论:扫描面本身覆盖全部**需要入缓存**的源类型(章/条目/信息差/名册/角色卡;摘要·时间线·卷纲·总纲无缓存表、按需读文件,非缺口)。migrate 产物落标准目录(`定稿/正文`、`大纲/伏笔`、`定稿/设定/名册·角色`),rebuild 能扫到。M6 threadCreates 落 `大纲/{类型}/`,scanThreads 覆盖。**真正的坑在事务/错误处理边界。**
+
+- `[P1] rebuilder.js:68-113 (scanChapters) 过宽 try/catch 把 readFile/解析/INSERT 失败一律当「目录不存在,跳过」,静默截断章扫描后仍 COMMIT ok:true | 章号最新的一章若解析失败/读失败/主键撞车会从 chapters 表消失;state-machine/index.js:50 读 MAX(chapter_num) 得到偏小值,序6 起草 maxChapter+1 = 已存在章号 → 重抄本章(正是 §5.3 要防的场景) | 建 3 定稿章,把最新章 front matter 改成坏 YAML(或再放一个同 章号 的 .md),rebuildFromSource 后断言 result.ok===false 或 warnings 非空,且 MAX(chapter_num) 仍为 3 | 中高(catch 过宽确定,触发概率中)`
+
+- `[P1] rebuilder.js:118-175 (scanThreads) 每类目录的 try/catch 同样吞掉 INSERT UNIQUE 违反,静默丢该目录后续条目并 COMMIT ok | threads.id 是主键;persist.js:98 卷复盘写 `大纲/伏笔/${e.id}.md` 未经 createThread 的重号校验,若与 finalize 建的 `伏笔-001-短题.md` 撞同 id,第二条 INSERT 抛错被吞 | 放 `大纲/伏笔/伏笔-001-a.md` 与 `伏笔-001-b.md` 两文件(都映射 伏笔-001),rebuild 后断言不是「静默丢一条 + ok:true」,而是报冲突或至少 warning | 中`
+
+- `[S] rebuilder.js:16-52 内层各 scan 的 try/catch 架空了外层 BEGIN/COMMIT 的「完整性违反必回滚」意图 | §5.1 要求「中途任何失败(含数据完整性违反)必须 ROLLBACK」,但只有 scanEntities:264-272 别名冲突走了 ok:false→ROLLBACK;chapters/threads/secrets/characters 的完整性错误被内层吞掉后照常 COMMIT | 对比 scanEntities 与其余四个 scan 的错误传播路径即可确认 | 高(结构性)`
+
+- `[P2] rebuilder.js:86/143/198/312 parseFrontMatter 失败分支静默跳过、不 push warning | §5.2 要求 best-effort 跳过要记 warning;当前坏 front matter 的章/条目/信息差从缓存凭空消失、零信号,作者与后续报表都看不到 | 喂一章坏 YAML front matter,rebuild 后断言 warnings 里有该文件的提示 | 高(代码明显未 push warning)`
+
+- `[P2] rebuilder.js:161 threads.short_title 被塞成 id(如 伏笔-001),丢弃文件名里的短题 | short_title NOT NULL 但语义写错;list-threads/审稿输入拿到的是 id 而非人读短题 | 建 `伏笔-001-灭门线索.md`,rebuild 后查 threads.short_title 是否 = 「灭门线索」而非 「伏笔-001」 | 高(必现),影响低`
+
+## 焦点 2:确定性(§2.5)
+
+> 结论:**确定性守住**。grep 全 `v7/src` 确认 `localeCompare`/`Math.random`/`Date.now`/`new Date`/`toISOString` 均不在 cache|style-stats|health-check(Date/random 只在 session 书单时间戳、git-health/goto 的 rescue ref,均不碰 fingerprints/imagery/缓存表)。排序一律 `cmp`(码元序)。对象键序由固定构造顺序保证(common_phrase_frequency 按已排序 imagery 插入、段落分布按固定 短/中/长/超长)。
+
+- `[CLEAN] style-stats/index.js 全模块纯计数 + 固定遍历 + cmp 排序,无时间戳/随机/locale | — | 删缓存跑两次 health-check,逐字段 diff fingerprints 与 meta.imagery_top | 高`
+
+- `[CLEAN] health-check/index.js:214 「基线与近段重合只落基线行」守恒 | fingerprints 主键 (start,end):基线 PK=(基线起,基线终)、近段 PK=(近段起,maxChapter),仅当 基线起===近段起 且 基线终===maxChapter 时同 PK,guard 精确覆盖该条件后 return,不再 upsert 近段;upsertFingerprint 是**唯一** is_baseline 写入点(rebuilder.js:49 明确不填 fingerprints) | 造「全书仍在基线区间内」的书跑 health-check,断言 fingerprints 只有一行且 is_baseline=1 | 高`
+
+## 焦点 3:staged/迁移不入缓存表与 meta(§7.2)
+
+> 结论:**未泄漏**。staging/index.js 只 `SELECT`(MAX(chapter_num)、is_baseline 基线行),无任何写缓存表/meta;叠加视图 stagedFacts/overlayBookStatus 全从批次文件现读。migrate 写缓存(migrate/index.js:68 rebuildFromSource)但那是**已转正的迁移定稿内容**,本就该入表,非 staged 泄漏。
+
+- `[CLEAN] staging/index.js 无 cache.run(INSERT/UPDATE);judgeStop:386 readBaselineFingerprint 只读 | — | grep staging 全文只有两处 cache.query(读),无写 | 高`
+
+- `[P2] cache/index.js:104 rebuild 用 preservedMeta 保留 imagery_top 跨重建,但改源刷缓存后不重算它,只在 health-check 才重算 | retcon/手改改了正文后,refreshCacheAfterSourceChange→rebuild 会把**旧** imagery_top 原样带过来;prep/index.js:108 与 mechanical-check/index.js:236 读 meta.imagery_top,在下次体检前拿到陈旧高频意象(提醒性、不拦截) | retcon 删掉某高频短语所在章,刷缓存后立即读 meta.imagery_top,看是否仍含该短语 | 高(必陈旧),影响低`
+
+- `[P2] rebuilder.js:26 vs cache/index.js:104 处理不对称:rebuild DELETE fingerprints 且不回填,却 preserve imagery_top | 裸 rebuild 后 fingerprints 空表,staging judgeStop:393/report-style-drift 读基线得空 → 句式漂移闸门在下次体检前静默跳过 | 删 .cache 后直接 rebuild、不跑体检,查 fingerprints 行数=0 并确认 judgeBatchQuality 走了 baselineFp=null 分支 | 高(必现),影响低(by-design「丢失重测无害」)`
+
+## 焦点 4:改源自刷缓存公约(§决策 34)
+
+> 结论:**六处改源流程全刷**。finalize/index.js:139、goto-chapter.js:59、retcon.js:44、persist.js:107(卷复盘)、persist.js:135(修复回写)、relink.js:25(手改补登) 都尾调 refreshCacheAfterSourceChange;finalizeBatch 逐章走 finalizeChapter 各刷一次;migrate 用 rebuildFromSource。仅 finalize.js 与 retcon.js 直接 new 源写入器,均刷;persistCreateBook 不刷但只写 总纲/卷纲/book.yaml(无缓存表源)且建书时缓存尚未建立,非缺口。**未发现漏刷的新改源点。**
+
+- `[CLEAN] 六改源流程 + finalizeBatch + migrate 均刷缓存 | — | grep `new (Chapter|Thread|Entity|Timeline|Secret|Summary)Writer` 只落 finalize/retcon,二者都刷;persist/relink 走 writeAtomicBatch 后都刷 | 高`
+
+- `[P2] refreshCacheAfterSourceChange 软失败(keepExistingOnFailure:false)删库 + this.db=null 后,同命令内后续 cache.query 会抛「数据库未初始化」 | 已核查六流程都把刷缓存放在尾部即 return;finalizeBatch 尾部 runHealthCheck 有 try/catch 兜底、批内下一章 finalizeChapter 不读缓存并会自愈重建,故当前无未捕获的刷后查询 | 若日后有流程在刷缓存后再 cache.query,需补 this.db 为空的判定 | 中(现状 OK,属回归防线)`
+
+## 焦点 5:体检只算定稿
+
+> 结论:**只吃定稿**。runHealthCheck→loadCorpus:181 从 chapters 表(`定稿/正文` 的 file_path)逐章读;调 assembleBookStatus(本体只查缓存定稿),**不是** overlayBookStatus(叠加 staged 的版本);findMissingTimeAnchors 用 chapters + TimelineReader(读 `定稿/设定/时间线` 文件);readExcludeNames 读 entities/aliases(定稿)。finalizeBatch:560-562 先删批次目录再跑体检。无 工作区/待定稿 入口。
+
+- `[CLEAN] health-check 输入面全为定稿;overlay 只在备料/审稿消费点、不进体检 | — | 造进行中批次(工作区/待定稿 有章)跑 health-check,断言报告章数=定稿数、不含 staged 章 | 高`
+
+## 焦点 6:node:sqlite 使用
+
+> 结论:注入面干净;连接生命周期基本安全;错误人话化有个别机器味泄漏点。
+
+- `[CLEAN] 注入面全参数化 | cache.query/run(sql, params)→prepare(sql).all/run(...params);动态 SQL(EntityReader.js:107-115、ThreadLedgerReader.js:186-194、list-chapters/read-chapters)只拼静态片段 + 绑定 ? 参数,无数据插值 | 通读上述四处确认 filter 值走 params.push 而非字符串拼接 | 高`
+
+- `[P2] cache/index.js:141-142 fs.rm(旧库,force) + fs.rename(临时→正式) 在 Windows 文件锁下 | force 只吞 ENOENT、不吞 EBUSY/EPERM;若有残留句柄持有 index.db,rm 抛错→外层 catch→_handleFailedRebuild→ok:false(临时库已 close,无损坏,仅重建失败)。单进程内只 this.db 持句柄且 :115-118 已 close,无 WAL 边车(无 PRAGMA WAL),风险低 | Windows 上开一个额外 DatabaseSync(index.db) 不 close,再 rebuildFromSource,看是否 EBUSY | 低`
+
+- `[P2] cache/index.js:74 ensureReady 抛 `缓存重建失败:${errors.join}`,errors 可能携带 node:sqlite 原文(如 "UNIQUE constraint failed: threads.id",英文机器味) | §6 要求错误路径人话化;其余刷缓存调用方(goto/relink)只显示固定中文提示、不回显 err.message,泄漏仅限 ensureReady 这条 throw | 触发一次 rebuild 硬错(造别名冲突),看冒泡到宿主的文案是否夹英文 | 中(英文确在),低频`
+
+---
+
+## 复查历轮修复在位(B 区相关)
+
+- P0-1 定稿后自刷缓存:finalize/index.js:137-139 在位 ✓
+- P0-3/P1-1 重建单事务 + 别名冲突 ROLLBACK:rebuilder.js:17/40-44 在位(但见焦点 1 的内层 catch 架空问题)✓/⚠
+- §5.3 临时库替换、刷新失败作废旧缓存:cache/index.js:123-142/159-170 在位 ✓
+- M5.5 统计确定性:焦点 2 复验守住 ✓
+
+## Caveats / 未覆盖
+
+- EntityReader/ThreadLedgerReader/list-chapters 的动态 SQL 属 A 区(storage/adapters、commands),此处仅确认它们消费缓存时参数化安全,未逐行审 A 区。
+- migrate 的 transform 产物完整性(是否漏迁 悬念/感情线/信息差 → 这些缓存表在迁移后为空)属 F 区;本区只确认「migrate 写到的目录 rebuild 能扫到」。
+- 焦点 1 各候选是「主会话真 CLI/库探针复现」的重点对象,尤其 scanChapters 静默截断 → next 重抄本章这条链。

+ 68 - 0
.trellis/tasks/07-05-m1-m7-review/research/review-C.md

@@ -0,0 +1,68 @@
+# Research: C 区审查——写章流程脚本面(prep / mechanical-check / review / finalize / dto)
+
+- **Query**: v7 第三轮全量 review 的 C 区(写章流程),聚焦 M6 threadCreates/staging 接缝、workspaceFiles 清理、机检阻断语义、两审 schema、备料降级、回滚边界、DTO 路径泄漏
+- **Scope**: internal(v7/src + v7/test 精读)
+- **Date**: 2026-07-05
+
+## 候选清单
+
+格式:`[severity] file:line 一句话问题 | 怀疑理由 | 建议探针 | 置信`
+
+### C1 [P2] v7/src/finalize/index.js:142-144 —— 提交后清工作区的 `fs.rm` 无 try/catch,可把已成功的定稿误报为失败
+- 问题:commit(127)与缓存刷新(139)之后的 `for (wf of workspaceFiles) fs.rm(join(repoPath,'工作区',wf),{force:true})` 既无 try/catch 也无 `recursive:true`;一旦某 `wf` 是目录或文件被占用,rm 抛错会冒泡到外层 catch(147),走回滚分支并返回 `{ok:false, error:'定稿中断,已回滚…'}`——但本章其实已 commit、缓存已刷。
+- 怀疑理由:①同函数 129-135 的备份删除**有** try/catch 兜底(注释"备份残留不影响已完成 commit"),139 的 cacheRefresh 也内部吞错永不抛(cache/index.js:14-21 已核)——唯独 142-144 裸奔,防御姿态自相矛盾;②`fs.rm({force:true})` 只吞 ENOENT,不吞 EISDIR/EBUSY/EPERM;③review 流程确实产出 `工作区/评审报告/` 目录(review/index.js:245-250),SKILL.md:39 又要求宿主把"本章用过的工作区文件全列进 workspaceFiles",宿主列进目录名即触发 EISDIR;④Windows 文件锁(历史 bug #6)同样触发。finalizeBatch 因 staging/index.js:542 置空 workspaceFiles 而免疫,故仅**手动单章 finalize** 暴露。
+- 建议探针:finalizeChapter 传 `workspaceFiles:['评审报告']`(真造该目录)或占用文件,断言仍返回 `ok:true` 且 commit 数 +1(即提交后清理失败绝不能反转成功结果)。
+- 置信:中
+
+### C2 [P2] v7/src/finalize/index.js:142(对比 staging/index.js:330、commands/finalize.js:21、commands/stage-chapter.js:18)—— finalizeChapter 本体不剥 `工作区/` 前缀也不防 `..`,非命令直调即静默漏清
+- 问题:finalizeChapter 直接 `path.join(repoPath,'工作区',wf)`。若 `wf` 带前缀(`工作区/细纲.md`),拼成 `repoPath/工作区/工作区/细纲.md` → 不存在 → force 静默 no-op → **漏清**(历史 bug #4 原型)。
+- 怀疑理由:stageChapter(330-331)与两个命令壳(finalize.js:21、stage-chapter.js:18)都做 `String(f).replace(/^工作区[\\/]/,'')`(+ stageChapter 还加 `..` 防护),唯独 finalizeChapter 无归一;命令层两处口径一致(无双源漂移),但 finalizeChapter 的安全**完全寄生**于调用方归一——防御深度不对称,任何新增直调点(或测试脚手架)传前缀名即复发。现网被 commands/finalize.js 掩盖故非活跃 bug。
+- 建议探针:直接 `finalizeChapter(ctx,{...,workspaceFiles:['工作区/细纲.md']})`,断言 `工作区/细纲.md` 仍在(复现漏清);对照走 `finalize` 命令则被清。
+- 置信:中(潜伏/防御缺口,非活跃断裂)
+
+### C3 [P2] v7/src/storage/adapters/ThreadLedgerWriter.js:99 与 ThreadLedgerReader.js:213 —— `_findThreadFile` 宽松 `startsWith(threadId)` 与 createThread 精确匹配口径不一致,非填充 id 会解析到错条目
+- 问题:两处 `_findThreadFile` 均 `files.find(f=>f.startsWith(threadId))`;而 createThread:41 用精确 `f===`${id}.md` || f.startsWith(`${id}-`)`。`伏笔-1` 会 startsWith 命中 `伏笔-10-xx.md`。
+- 怀疑理由:createThread 正则 `/^\S+-\d+$/`(:26)**不强制** 3 位零填充,宿主可传 `伏笔-1`;updateThread/appendHistory(:61/:81)、finalize threadUpdates(index.js:82)、retcon(flows/retcon.js:34)、review 履历尾部(review/index.js:160 经 ThreadLedgerReader)全部过 `_findThreadFile` → 命中错文件即**改到/读到别的条目**(串写、履历错栏)。现网 id 经 migrate/transform.js:86 与约定统一 3 位填充(伏笔-001…999,互不为前缀),故不撞;但校验层没兜住非常规 id。
+- 建议探针:createThread 造 `伏笔-1` 与 `伏笔-10`,再 `updateThread('伏笔-1',{状态:'放弃'})`,断言被改的是 伏笔-1 文件而非 伏笔-10。
+- 置信:低-中(需非填充 id,现网填充纪律掩盖)
+
+### C4 [S] v7/src/staging/index.js:331 —— `..` 防护用 `!name.includes('..')`,误伤合法双点文件名
+- 问题:stageChapter 清工作区时 `if (!name.includes('..')) clears.add(name)`,会把 `a..b.md` 之类合法名整条排除 → 该文件漏清。
+- 怀疑理由:本意防路径穿越,但 `includes('..')` 过宽;影响面是漏清而非越权删,方向安全但语义过严。
+- 建议探针:payload.workspaceFiles 含 `笔记..草稿.md`,断言是否被清(确认过宽拦截边界)。
+- 置信:低
+
+### C5 [S] v7/src/mechanical-check/index.js:130 —— 新专名启发式正则 `[一-龥]{2,3}` 仅覆盖 BMP 基本汉字区,漏扩展区/生僻姓氏
+- 问题:`/([一-龥]{2,3})(冷笑道|笑道|…|道|说|喊|问)/` 的姓名捕获用 `[一-龥]`(U+4E00–U+9FA5),扩展 A/B 区生僻字姓名进不了候选 → 漏报"疑似新专名"。
+- 怀疑理由:纯提醒项(candidates,PRD 明确非阻断),仅弱化提醒完整度,不构成流程断裂;但属机检覆盖边界,记以备裁。
+- 建议探针:正文写"生僻字姓名+道",断言是否进 `candidates`(确认漏报边界)。
+- 置信:低(advisory-only)
+
+## 历轮修复在位复查(逐条确认在位)
+
+| 修复点 | 位置 | 结论 |
+|---|---|---|
+| P0-1 定稿后同步刷缓存(防 next 读旧章号重抄) | finalize/index.js:139 + refreshCacheAfterSourceChange 内部吞错 | ✓ 在位,不抛 |
+| P1-7 回滚收窄到本次 written 集合(不整棵子树) | finalize:150-156,逐文件 restore + scoped clean;test finalize.test.js:106 真 git 验证不误伤他章手改 | ✓ 在位有测 |
+| threadCreates 原子边界(进同一 stage/rollback) | finalize:67-72(created 文件推入 stage+rollback,clean 只删未跟踪→既有条目安全) | ✓ 逻辑正确 |
+| threadCreates 撞号/非法类型校验 | ThreadLedgerWriter.createThread:26(类型+格式)、41(重号);test finalize.test.js:214 撞号整体失败干净回滚、236 断电新条目不残留 | ✓ 在位有测 |
+| workspaceFiles 前缀归一 + finalize-batch 置空约定 | commands/finalize.js:21、stage-chapter.js:18 归一;staging:542 置空(转正时不塞回) | ✓ 在位(但本体缺兜底=C2) |
+| 机检 pass=issues.length===0;候选不进 issues 不阻断 | mechanical:49;test check.test.js:307 显式"句式偏离只进候选不进 issues"、259/275 高频意象命中仍 pass=true | ✓ 在位有测 |
+| 两审 schema:严格布尔/critical 强制/unregistered_thread 恒非阻断/坏输入不抛/计数复算 | schema:53,65,66,67,71;test schema.test.js:23/29/70/81 全覆盖 | ✓ 在位有测 |
+| 备料八组件缺数据降级人话 | prep/index.js(无细纲/时间线/文风/未体检均中文占位,全 try 兜);test prepare.test.js:35 未体检占位 | ✓ 在位(缺细纲/时间线/文风分支未直测,仅代码在位) |
+| DTO 不泄漏文件路径 | dto/character-context.js 只回领域字段;review 相关条目显式不含 file_path(review:117/124) | ✓ 在位 |
+
+## 已核清白(怀疑点排除,附理由)
+
+- **"known 集合并入 staged 把提醒变阻断"**:REFUTED。staged 只影响候选(checkNewProperNouns:127-128 把 staged 新实体加进 known → **抑制**新专名候选;checkSecretKeywords:152 增加信息差候选,均非阻断)与条目变动形式检查(本就阻断,staged 感知反而**避免**K+1 推进 K 章新开条目的假阻断);不存在提醒→阻断的错误转化。mechanical:36 与 prep:22/review:30 均 `{before:chapterNum}`、stagedFacts:131 严格 `<` 过滤,无后章倒灌。
+- **finalize-batch 复用 workspaceFiles 语义**:staging:542 显式 `payload.workspaceFiles=[]`(注释"批次目录由本函数自管,防误删他章工件"),即便 定稿包.json 里带 workspaceFiles 也被归零;批次路径不重塞。
+- **回滚误删既有条目**:手工推演 threadCreates(新,未跟踪)+ threadUpdates(旧,已跟踪)混合场景——catch 里 `git.restore` 复原已跟踪旧条目、`git.clean -fd`(storage/finalize/git.js:52)只删未跟踪→旧条目安全、新条目被清。既有条目不会被误删。
+- **同章重写旧文件备份**:ChapterWriter.backupOldChapterFiles 备份名 `.wnwbackup.PID.N` 不以 .md 结尾(不进缓存扫描,finalize:133 注释依赖此);回滚 157-160 rm+rename 复原;test finalize.test.js:125 真 git 验证旧章净恢复、新章不残留。
+- **角色/名册回滚路径漂移**:finalize:93/100 构造的 `${name}.md`、`名册.md` 路径与 EntityWriter.updateCharacter:26 / upsertRosterRow:45 **完全一致**,无路径双源。
+- **批内条目 type 与 DB 不一致**:review:144 `类型英文[t.type]` 把 staged 中文类型(伏笔)转英文(foreshadow),恰好对齐 cache(rebuilder.js:120-122 存英文),同一"相关条目"清单里 DB 行(:126 `r.type`)与批内行 type 值一致,非 bug。
+
+## 测试脚手架掩盖审计
+
+- finalize.test.js 全程 `gitBookCtx()`(_helper.js:77 真 `git init`+config+add+commit)+ `createGit` 真跑 git,断言真实路径(0003-初露.md / `ch(3):` commit / revCount / execFileAsync git status)——**非脚手架掩盖**,是真实定稿流程。唯 gitBookCtx 把种子章打成单个 `init` commit(非逐章 `ch(N):`),故依赖 `findChapterCommit --grep=ch(N):` 的流程(retcon/relink,D 区)在此 fixture 下抓不到章 commit,C 区 finalize 测试不依赖,不影响本区结论。
+- mechanical-check/check.test.js、review/schema.test.js、prep/prepare.test.js 断言口径与实现一致,无文案级漂移。
+- **覆盖缺口**(非 bug,供 backlog):①机检 staged 叠加(checkThreadDeclarations/新专名/信息差 with staged)无直测,仅经 finalize.test.js:169 走"定稿→缓存→下一章"间接验证 threadCreates 接力;②备料缺细纲/缺时间线/缺文风分支未直测(fixture 全量齐备);③C1 的 workspaceFiles 目录/占用场景、C2 的直调前缀场景均无测。

+ 100 - 0
.trellis/tasks/07-05-m1-m7-review/research/review-D.md

@@ -0,0 +1,100 @@
+# Review D 区:状态机与外环(state-machine / session / runtime)
+
+- **范围**:v7/src/state-machine/(index/detectors/dto/persist/git-health/flows)、v7/src/session/、v7/src/runtime/
+- **对照**:story-repo-spec 0.13 §10(序 0-6)、§9、M1-M5 review 三条 P1
+- **日期**:2026-07-05
+- **性质**:只审不修。候选格式 `[severity] file:line 问题 | 怀疑理由 | 建议探针 | 置信`
+
+---
+
+## 一、候选清单(按严重度)
+
+### P1-D1 persistRepair 校验用错解析器,序0 修复对 book.yaml / 名册 / 时间线 三类必然失败
+
+`[P1] v7/src/state-machine/persist.js:123 | 置信=CONFIRMED(静态链路 + 测试空白已复核,未跑 CLI 探针)`
+
+- **问题**:`persistRepair` 对**每一条**修复内容都用 `parseFrontMatter(r.content)` 校验(persist.js:118-127)。但序0 源文件清单里有 3 类不是 front-matter markdown:
+  - `book.yaml`:纯 YAML,`serializeYAML` 输出 `key: value` 无 `---` 围栏(yaml-dialect.js:39;detectors.js:39-45 经 BookConfigReader 检测)
+  - `定稿/设定/名册.md`:纯表格,`serializeMarkdownTable` 输出(EntityWriter.js:63;detectors.js:57-63 用 parseMarkdownTable 检测)
+  - `定稿/设定/时间线/第NN卷.md`:纯表格(TimelineWriter.js:42;detectors.js:66-75)
+- **怀疑理由**:这三类的正确修复内容首个非空行不是 `---`,`parseFrontMatter` 直接返回 `ok:false,缺少 front matter 分隔符`(front-matter.js:30-39)。于是 persistRepair 在 :124 判定「修复内容仍解析失败」拒写。**序0 在状态机最前(index.js:23-26),命中即停**——一旦 book.yaml/名册/时间线 表损坏,序0 每次都触发,而唯一出口 persist-repair 又拒收合法修复 → 书被锁死在序0,作者无法在工具内自愈(违背序0「永不带堆栈崩溃/AI 提议作者确认」承诺)。检测侧用对了解析器(parseFrontMatter vs parseMarkdownTable),只有修复回写侧校验一刀切,是读写不对称。
+- **测试为何漏**:persist.test.js:89-127 三个 persistRepair 用例全用 `定稿/正文/0001-起.md`(front-matter 文件),从未覆盖 book.yaml/名册/时间线,正是「流程自身绿、边角没人测」。
+- **建议探针**:`persistRepair(ctx, {repairs:[{file:'定稿/设定/名册.md', content:'| 正名 | 别名 |\n|---|---|\n| 林晚 |  |\n'}]}, {allowedFiles:['定稿/设定/名册.md']})` → 预期错返 `修复内容仍解析失败(定稿/设定/名册.md):缺少 front matter 分隔符`。book.yaml 同理。
+
+### P1-D2 goto-chapter 回退不识别进行中批次 → 批次孤儿 + 定稿章号断档
+
+`[P1] v7/src/state-machine/flows/goto-chapter.js:38-57 | 置信=CONFIRMED(静态推演;建议 CLI 探针坐实)`
+
+- **问题**:`工作区/待定稿/` 批次被 gitignore(persist.js:60 建书 / migrate:50 迁移都 ignore `工作区/`),是**未跟踪**文件。goto-chapter 只 `createBackupRef + resetHard`(:54-57),不带 `git clean`,故 `reset --hard` **不删批次**;脏树检查 `dirtyScoped` 只看 `定稿/大纲`(:41-44),gitignore 文件不进 porcelain,**批次在场也不拦回退**。
+- **怀疑理由**:设 ch1-10 已定稿、批次暂存 ch11-18,作者「回到第8章」:reset 把 HEAD 退到 ch8(ch9/10 退出工作树,仅存 rescue ref),缓存重建后 maxChapter=8;批次 ch11-18 原样存活但**基线已从 ch10 变 ch8**。随后 next 命中序3 批次续跑(序3 优先于序4/5,批次期这些例外反而进不来,唯 goto 是绕过序路由的直呼命令);finalize-batch 逐章转正会写出 0011…,得到 1-8、11-18、**缺 9-10 的定稿断档**,且 staged 正文引用的是已被退掉的 ch9/10 世界态。全程无一处告警把「回退」和「在场批次」联系起来。stage-chapter 连续性校验(staging/index.js:289-295)在 batch.exists 时只对批尾续号,也不校 HEAD 断层。
+- **非「误删」澄清**:批次文件不会被删(gitignore + 无 clean),风险是**孤儿 + 章号断档 + 悬空引用**,任务问的两种里前者不成立、后者成立。
+- **测试为何漏**:grep 无 goto×待定稿 交叉用例(auto-mode.test.js:88 只测 next 命中批次续跑)。
+- **建议探针**:sample-book 里 stage ch3 批次 → `goto-chapter 1 --confirm` → `next --json`,看是否仍出序3 批次续跑且批次起章 > maxChapter+1(断档),且无任何冲突提示。
+
+### P2-D3 文风铁律 修复回写后永不入档(序2/relink 范围仅 定稿/大纲)
+
+`[P2] v7/src/state-machine/persist.js:134 与 detectors.js:99 | 置信=PLAUSIBLE`
+
+- **问题**:persistRepair 注释明写「修复本身不 commit:入档走序2 手改补登(relink)」(persist.js:134)。但序2 检测 `listManualEdits` 只收 `定稿/大纲` 前缀路径(detectors.js:99),relink 也据此圈范围(relink.js:16)。`文风/文风铁律.md`(front-matter 文件,能被 persistRepair 写成功)落在 定稿/大纲 之外 → 序2 永远看不见 → relink 不会提交它。
+- **怀疑理由**:修复后的文风铁律长期停在未提交区,作者无从经 relink 入档;且后续任一 goto-chapter 的 `reset --hard`(dirtyScoped 同样只看 定稿/大纲,:41-44)会静默抹掉这个已跟踪文件的改动。book.yaml 同类,但它更早卡在 P1-D1 写不进来。
+- **建议探针**:修好 `文风/文风铁律.md` 走 persistRepair → `next --json` 看是否出序2;`git status` 看 文风/ 是否长期 dirty;relink 后确认文风铁律仍未被提交。
+
+### P2-D4 retcon 失败回滚过宽,可误伤并发未提交手改(bug 模式#8)
+
+`[P2] v7/src/state-machine/flows/retcon.js:48-49 | 置信=PLAUSIBLE`
+
+- **问题**:retcon 失败分支执行 `git.restore(['定稿/','大纲/'])` + `git.clean(['定稿/','大纲/'])`——整棵子树回滚。finalize 早已刻意改为逐文件回滚,并在注释里点名「非整棵 定稿/大纲 子树,避免误伤同子树其他章手改」(finalize/index.js:148-149)。retcon 仍是宽版。
+- **怀疑理由**:retcon 是直呼命令,若作者本有 定稿/大纲 未登记手改(正处序2 态)时吃书中途失败,宽版 restore 会把无关手改一起还原、clean 会删掉无关未跟踪新文件。回滚边界过宽正是 PRD 闻味#8。
+- **建议探针**:定稿/ 放一处未提交手改 + 一个未跟踪新角色卡 → 触发一个中途失败的 retcon(thread 更新指向不存在条目)→ 看手改被 restore、新文件被 clean。
+
+### P3-D5 序3 DTO 组装走读路径却可能写 批次.json(readBatch 自愈副作用)
+
+`[P3] v7/src/state-machine/dto.js:75 → staging/index.js:74-78 | 置信=PLAUSIBLE`
+
+- **问题**:`next` 路由组 DTO 时 dto.js:75 调 `readBatch`;readBatch 在「批次.json 缺失/损坏但有章目录」时会 `writeAtomicBatch` 重建元数据(staging:74-78)。即状态机的读路径隐含一次落盘。
+- **怀疑理由**:自愈本身合理,但发生在名义只读的路由组包里,且同一次 DTO 内 readBatch 被多次调用(dto.js:75 一次、judgeStop→stagedFacts 再各一次 staging:114/357),重建会重复写。属稳健性/意外副作用,非数据错。
+- **建议探针**:删 `工作区/待定稿/批次.json` 保留章目录 → `next --json`,看是否静默重写出 批次.json。
+
+### P3-D6 「待定稿/」现存判定与 readBatch.exists 口径不一致
+
+`[P3] v7/src/state-machine/detectors.js:129 vs v7/src/staging/index.js:80 | 置信=PLAUSIBLE`
+
+- **问题**:序3 现存把 `待定稿/` 只按 `readdir(...).length>0` 计入(detectors.js:129);readBatch 则要求有 `^\d{4}-` 章目录或有效 meta 才 `exists:true`(staging:42-82)。当 待定稿/ 只含杂项文件时,现存含「待定稿/」但 batchDetail 因 exists=false 返回 `{}`(dto.js:76)。
+- **怀疑理由**:DTO 会出 `从哪继续='待定稿批次续跑'` 却无 `批次` 明细字段,AI 拿到「续跑」却无可跑内容。非崩溃,属边角不一致。
+- **建议探针**:`工作区/待定稿/` 放一个非章目录杂文件 → `next --json` 看 从哪继续 与是否缺 批次 字段。
+
+### P3-D7 序4/5/6 前的章号查询未加 try/catch
+
+`[P3] v7/src/state-machine/index.js:46-48 | 置信=Low`
+
+- **问题**:`cache.query('SELECT ... FROM chapters ...')`(:46-48)无 try/catch,而同文件 readLastHealthCheck(:78-83)有。缓存查询若在路由中途抛错,determineNextState 直接抛。
+- **怀疑理由**:调用方(next 命令)通常已 ensureReady,实际触发面窄,故 Low。仅口径不一致提示。
+- **建议探针**:注入一个 query 抛错的 cache 跑 determineNextState,看是否冒泡未捕获。
+
+---
+
+## 二、历轮修复在位复查(M1-M5 三条 P1)
+
+| 项 | 结论 | 证据 |
+|---|---|---|
+| **P1-1 卷复盘 commit `vol(NN)`** | ✅ 在位 | persist.js:104 `commit(`vol(${nn}): 复盘与下卷规划`)`,有 ensureIdentity + hasStagedChanges 守卫(:102-105) |
+| **P1-2 五处改源自刷缓存** | ✅ 全在位 | finalize/index.js:139、goto-chapter.js:59、retcon.js:44、persist.js:107(卷复盘)、persist.js:135(修复回写),均调 `refreshCacheAfterSourceChange` |
+| **P1-3 relink 执行通道** | ✅ 在位 | v7/src/commands/relink.js(add+`fix(手改)` commit+刷缓存,与 listManualEdits 同源圈范围);dto.js:33 序2 期望产物指向 relink;detectors.js:36 message 带补登命令。**但见 P2-D3:文风/book.yaml 不在 relink 覆盖面** |
+
+## 三、序 0-6 与 spec 0.13 §10 逐条对照
+
+- **序0**:detectors.js:13-77 八类清单与 §10「源文件清单」逐条一致(正文/伏笔/悬念/感情线/角色/信息差 front matter + book.yaml + 文风铁律 + 名册表 + 时间线表),钉死范围未自行增减,缺失跳过、存在才校。**检测侧正确**;修复回写侧见 P1-D1。
+- **序1**:detectors.js:81-88 无 book.yaml 判定;「当前书不存在」子情形在 locate 层 workdir-no-book(locate.js:44-57)分担,index.js:16 `!repoPath` 兜空目录。范围一致。
+- **序2**:detectors.js:92-106 定稿/大纲 未提交改动,与 §10 一致(检测=执行同源,不双写)。
+- **序3**:detectors.js:117-145 续跑映射与 §10 映射表一致(待定稿 > 审稿 > 草稿 > 材料 > 细纲,最深优先;细纲计入不被序6覆盖)。dto batchDetail 建议命令(stage-chapter/save-review+batch-restage/batch-status+finalize-batch)均对应真实命令,文案与可跑命令一致。
+- **序4**:收卷声明制到位——rebuilder.js:100 `收卷:是→is_volume_end`,index.js:55 读 is_volume_end + volumeReviewDone 防重复触发(detectors.js:153-161 以卷摘要存在为准),与 §10 序4/§4.1/§7 一致,未用章号整除。
+- **序5**:index.js:63 `maxChapter-lastCheck >= 体检周期`,默认 50(index.js:52 / book.yaml),与 §10「距上次体检已满周期」一致;记录存 meta,丢失重测无害(readLastHealthCheck 容错)。
+- **序6**:默认分支,自动确认细纲标志经 book.yaml 读入 dto(dto.js:56-65),与 §10 一致。
+
+## 四、其余专项(无新候选)
+
+- **git 健康检查**(git-health.js):锁文件/损坏/网盘副本/合并/半提交五类各有中文 fixed/guidance/rescued,损坏只指引不动仓库(:36-41),救援写 工作区/.救援/修复日志.md(:115-124),「作者永不直面 git 报错」守住。
+- **persist 落盘**:序0 安全网「只写 allowedFiles 内 + 内容须解析」在位(persist.js:118-127,唯解析器选型是 P1-D1);建书 git init 幂等 + ensureIdentity 身份兜底 + hasStagedChanges 守卫(persist.js:69-77);bookAgentsMd 被 migrate 复用(migrate:7,49),文案「本目录是《书名》的书仓库」对迁移语义仍成立。
+- **books.jsonl 自愈**(session/index.js):坏行跳过计数、missing/空→扫描重建回写、部分损坏→留好行回写(:70-96),登记/换书各保证唯一「当前」;并发 last-write-wins 有注释认账。
+- **locate 三分支**(locate.js):book/workdir/workdir-or-book/anywhere 四 scope 齐;workdir-no-book 覆盖「当前书未选」与「登记书目录缺 book.yaml」两支,人话提示。
+- **批次期其余例外**:卷复盘(序4)/体检(序5)在批次期被序3优先拦截,不冲突;relink 在批次期只碰 定稿/大纲、不动 工作区批次;手动 finalize 单章不经序路由,正常流程不会在批次期触发。唯 goto-chapter 是绕过序路由的直呼命令,故成 P1-D2 的唯一交叉风险点。

+ 151 - 0
.trellis/tasks/07-05-m1-m7-review/research/review-E.md

@@ -0,0 +1,151 @@
+# Review E 区:M6 staging 与三消费点叠加 + 批次×手动流程交叉
+
+- **审查员**:Research Agent(E 区)
+- **范围**:v7/src/staging/、prep/review/mechanical-check 叠加分支、6 个 batch 命令、dto.batchDetail、goto/relink/finalize/retcon/impact/export 交叉接缝
+- **日期**:2026-07-05
+- **对照**:spec §8.1(decisions #36/#20)、§9/§10、PRD「本轮新增关注」
+- **方式**:只读精读 + 代码在位性核销(无 CLI 探针,标注建议探针供主会话复现)
+
+候选计数:**P1×2 / P2×4 / S×3**;另核销「在位正确」7 项(见末节)。
+
+---
+
+## P1(支路断 / 数据丢)
+
+### E1. goto-chapter 回退定稿到批次起章之下 → 孤儿批次 + 定稿章号静默缺口
+
+`[P1] v7/src/state-machine/flows/goto-chapter.js:36-69 + v7/src/finalize/index.js:40-42`
+
+- **问题**:goto-chapter 全流程零批次感知,回退 `定稿/` 到 active 批次 `起章` 之下后,`工作区/待定稿/` 批次原样残留,随后 finalize-batch 把 staged 章按原章号写回 `定稿/`,中间缺口静默丢失。
+- **怀疑理由**:
+  - `grep batch|待定稿|readBatch` 在 goto-chapter.js / finalize.js / finalize/index.js **零命中**(本轮实测 exit 1)——无任何批次守卫。
+  - goto 的脏树检查(goto-chapter.js:40-52)只看 `定稿/大纲` 前缀,`工作区/待定稿/` 不在检查内;`git reset --hard`(:57)只动 git 跟踪文件,批次目录(工作区,未入 git)存活。
+  - 场景:定稿到 100 章,批次 stage 101-105(预登记事实建立在 96-100 上)。作者 `goto-chapter 95 --confirm` → 定稿回到 95,批次 101-105 仍在。`next` → 序 3(待定稿/ 存在)→ 批次续跑,建议照给。finalize-batch → finalizeChapter 无连续性校验(:40-42 只校验 `Number.isInteger` 与 `标题`),ChapterWriter 按号写 `0101-*.md` → 定稿变 [1..95, 101..105],**96-100 静默缺口**,且 threadUpdates 履历从 ch95 跳到 ch101。
+  - spec §8.1「goto 只管已定稿回退、丢弃批次管未定稿,两者职责不混」——设计意图是互斥,但**无代码强制**。
+- **建议探针**:脚本建 book 定稿到 ch5,stage 6-8,`goto-chapter 3 --confirm`,再 finalizeBatch,断言 `定稿/正文` 出现 [1,2,3,6,7,8] 缺口 / 或期望的拒绝。
+- **置信**:HIGH(守卫缺失可证;缺口后果为代码路径直接推导)。
+
+### E2. 批次进行中手动 finalize 单章 → 章号双计 + finalize-batch 卡死
+
+`[P1] v7/src/commands/finalize.js:26 + v7/src/finalize/index.js:22-42`
+
+- **问题**:手动 `finalize <章号>` 命令对 active 批次无守卫、无连续性校验;定稿一个已在批次内暂存的章号,会在叠加视图双计,并使 finalize-batch 卡死。
+- **怀疑理由**:
+  - finalize.js 全文无批次检查;finalizeChapter 校验仅 `Number.isInteger(chapterNum)` 与 `frontMatter.标题`(:41-42),不比对定稿最大章号 / 批次起章。
+  - 双计:批次 stage 101-105,手动 finalize 101 后缓存含 101。`overlayBookStatus`(staging/index.js:235)`总章数 = 定稿总章数(含101) + staged.length(含101)`、`卷内进度`(:240-246)同样把 101 计两次。
+  - 卡死:随后 finalize-batch 再定稿 101 → finalizeChapter 跑 threadCreates → `ThreadLedgerWriter.createThread` 对已存在条目返回 `{ok:false, '条目 … 已存在'}`(ThreadLedgerWriter.js:42)→ finalizeChapter 回滚失败 → finalize-batch 停在 101,批次卡住。
+  - PRD 本轮新增关注点名此场景「手动 finalize 单章(绕过批次直接定稿下一章→章号连续性与批次起章错位)」。
+- **建议探针**:stage 3-5 后 `finalizeChapter(ctx, chapterPayload(3))`,再 `finalizeBatch(ctx)`,断言卡在第 3 章「已存在」;另断言 overlayBookStatus 总章数把 3 计两次。
+- **置信**:HIGH(守卫缺失可证);卡死链路 PLAUSIBLE(依赖 createThread 已存在语义,建议探针坐实)。
+
+---
+
+## P2(稳健性)
+
+### E3. finalizeBatch 循环无 try-catch:转正后清目录 / 写 meta 失败会抛裸栈且留状态不一致
+
+`[P2] v7/src/staging/index.js:529-556`
+
+- **问题**:per-章循环无 try-catch。finalizeChapter 已 commit 后,`fs.rm(dirP)`(:553)或 `writeAtomicBatch(metaFile(remaining))`(:555)抛错会裸栈冒泡(作者直面堆栈,违不变量 8),且批次.json 仍把已定稿章列为「待审收」。
+- **怀疑理由**:
+  - finalizeBatch(:508-566)无外层 try-catch;finalize-batch.js:15 直接 `await finalizeBatch` 也无兜底 → 抛到 CLI dispatcher。
+  - 顺序是 finalizeChapter(已 commit + 刷缓存) → rm 目录 → 更新 meta。若 rm/meta 在中途抛(Windows 文件锁/权限),该章已入档但 meta 未更新;重跑 finalize-batch 会对该章再定稿 → createThread「已存在」→ 卡死(同 E2 尾链)。
+  - 对比:stageChapter(:274-341)与 finalizeChapter(:47-172)都有外层 try-catch,finalizeBatch 独缺。
+- **建议探针**:mock `fs.rm` 在第 2 章抛 EPERM,跑 finalizeBatch,断言是否裸抛 / 批次.json 与磁盘失步。
+- **置信**:MEDIUM(触发需 fs 错误,PLAUSIBLE)。
+
+### E4. stageChapter 覆盖重暂存:writeAtomicBatch 成功后清旧目录/工作区抛错 → 报失败但批次已写
+
+`[P2] v7/src/staging/index.js:315-338`
+
+- **问题**:`writeAtomicBatch`(:315)已落新批次文件后,旧目录清理(:324)或工作区清理(:333-335)抛错,会走外层 catch 返回 `ok:false`,但批次实际已写入。
+- **怀疑理由**:
+  - 「零写入承诺」对**校验失败**成立(:277-306 所有校验都在 writeAtomicBatch 之前,审稿单缺失等分支零副作用,在位正确)。
+  - 但覆盖重暂存改标题清旧目录 `fs.rm`(:324)、工作区 `fs.rm`(:334)在 writeAtomicBatch 之后;抛错 → catch 返回 `ok:false`(:339-341),宿主视作未暂存重试。重试再 stage 覆盖(幂等性尚可),但「失败=零副作用」的直觉在此不成立。
+  - 旧目录若清理失败残留:readBatch 的 `seen.has(num)` 按章号去重(:69)会跳过同章号旧目录 → 逻辑无害,但磁盘孤儿目录永久残留(次要泄漏)。
+- **建议探针**:改标题重 stage 时 mock 旧目录 rm 抛错,断言返回值与批次落盘是否矛盾。
+- **置信**:MEDIUM。
+
+### E5. finalize-batch --until「前几章先发」后,剩余待审收章的续跑建议指向「继续写下一章」而非「转正剩余」
+
+`[P2] v7/src/state-machine/dto.js:80-88`
+
+- **问题**:`--until` 只转正前段后,剩余章全为「待审收」,batchDetail 建议落到 else 分支「继续批内下一章(第 N+1 章)」,而非提示「剩余 X 章已就绪,可 finalize-batch 转正」。
+- **怀疑理由**:
+  - batchDetail 建议优先级(:80-88):打回 > 受影响 > 停止 > else「继续下一章」。剩余章非打回非受影响;原停止因(写满)在移除前段后已不成立(judgeStop 只数当前 staged 数),故 `停止.stop=false` → else 分支。
+  - finalize-batch.test.js:246-247 已断言此路径回序 3,但未校验建议文案的合理性;spec §8.1「支持只转正前段(前几章先发)→ 下一批次」,就绪的剩余章应被指向转正而非继续堆章。
+  - 后果:作者跟着建议写 106,批次又长回去,原写满停止语义丢失(可无限增长)。
+- **建议探针**:stage 3-5,finalizeBatch --until=4,取 buildDto(序3),断言 `批次.建议` 是否误导为「继续下一章」。
+- **置信**:MEDIUM(文案完整性,非数据错,经 --until 测试路径确认)。
+
+### E6. 批次.json 损坏重建把已打回空目录降级为不可操作的「受影响」行
+
+`[P2] v7/src/staging/index.js:74-78 + 452-475`
+
+- **问题**:批次.json 丢失时全量按目录重建、一律标「受影响」(保守,:75 注释在理);但 rejectFrom 保留的空目录(清了草稿/定稿包/审稿单)会重建成一条「受影响」行,其草稿/定稿包已不存在 → 既不能 finalize(读定稿包失败)也不能 restage(无审稿)。
+- **怀疑理由**:
+  - rejectFrom(:469-472)删 3 件套但保留目录 + 元数据行(状态=打回)。批次.json 损坏后走重建分支,`rowFromDir`(:85-97)读不到草稿 → warn + 仍建行、状态硬编码「受影响」(:96)。
+  - 该行 finalizeBatch 时读 `定稿包.json` 失败(:533-540)→ 整批停;batch-restage 读 `工作区/审稿.md` 也无(且它本是打回章,restageReview:485-487 会拒)。→ 死行,须作者手动 rewrite。
+  - 「起章/连续计数怎么恢复」子问:起章 = 目录最小号(:82,无损);连续无变动 = judgeStop 从 front matter 现算(:378,无损)——二者派生、重建无损。**唯一丢失是「打回 vs 待审收」状态区分**,且打回空目录退化为死「受影响」行。
+- **建议探针**:stage 3-5、reject 4、删批次.json、readBatch,断言第 4 章行状态与可操作性。
+- **置信**:MEDIUM。
+
+---
+
+## S(spec 漂移 / 低风险双写)
+
+### E7. 批次.json 持久形态与 spec §8.1 声明字段漂移(起章/连续计数未落盘,靠派生)
+
+`[S] v7/src/staging/index.js:99-104`
+
+- **问题**:spec §8.1 称批次.json 存「起章、各章{章号,标题,状态}、连续无条目变动计数」;`metaFile` 只持久 `{章列表}`。
+- **怀疑理由**:起章(rows[0].章号)与连续计数(judgeStop 现算)均为派生量,落盘缺失不致数据丢(重建无损,见 E6),但持久 schema 与 spec 文本不符——属实现口径回填时的文档漂移。
+- **建议探针**:无需运行;文档核对即可。
+- **置信**:HIGH(漂移属实,影响近零)。
+
+### E8. 弱钩判据 `includes('弱钩')||endsWith('-弱')` 四处双写
+
+`[S/low] v7/src/staging/index.js:252、419;v7/src/prep/book-status.js:34;v7/src/commands/report-weak-hook-streak.js:14`
+
+- **问题**:弱钩识别谓词在 4 处逐字复制(staging 内叠加显示:252 与停止判据:419 各一份,另 book-status、report-weak-hook-streak 各一份)。
+- **怀疑理由**:钩子格式若变(如新增「弱钩」写法),4 处须同步改,漏一处即判据漂移。历史 bug 模式 #7(常量双写)同类。风险低(当前格式稳定、逻辑简单)。
+- **建议探针**:无需运行;建议抽 `isWeakHook()` 单点。
+- **置信**:HIGH(双写属实,风险低)。
+
+### E9. 停止「连续无条目变动」只读 front matter 声明,与 stagedFacts 的 payload 条目来源可分叉
+
+`[S] v7/src/staging/index.js:377-384 vs 152-193`
+
+- **问题**:judgeStop 连续无变动计数只看 `parseThreadDeclarations(frontMatter)`(:379),而 stagedFacts.threads 还从 `payload.threadCreates/threadUpdates`(:178-193)派生。若宿主直接构造 payload 带条目变动但 front matter 三数组为空,会被判「无变动」。
+- **怀疑理由**:spec §8.1 明写停止判据 =「front matter 三数组声明全空」,故以 front matter 为准属**故意**;但 front matter 与 payload 由宿主分别提供、可分叉,属契约脆弱点(正常流程二者一致,机检/审稿也都读 front matter,在位一致)。
+- **建议探针**:stage 一章 front matter 空三数组但 payload 有 threadCreates,看是否误触连续无变动停止。
+- **置信**:MEDIUM(契约提示,非缺陷)。
+
+---
+
+## 在位正确 / 复查通过(探针核销,非候选)
+
+1. **before 过滤三消费点全传对**:prep/index.js:22、review/index.js:30、mechanical-check/index.js:36 均 `stagedFacts(repoPath,{before:chapterNum})`——重审受影响章不倒灌后章事实。✓
+2. **容差常量同源不双写**:`AVG_SENTENCE_LEN_TOLERANCE`/`SENTENCE_VARIANCE_TOLERANCE` 唯一定义在 style-stats/index.js:12-13,staging(:11-14)与 mechanical-check(:6)均从该处 import,spec §8.1「口径同机检」达成。✓
+3. **parseThreadDeclarations 共用解析器**:staging:152、review:37、mechanical-check:193 共用 util/thread-declarations.js,条目声明解析不双写。✓
+4. **无批次零行为变化**:stagedFacts 无批次返回 `exists:false` + 空集合;overlayBookStatus 早退(:231);三消费点对空集合/空 Map/空 Set 循环即无操作;readBatch().exists 各消费点均有防护。✓
+5. **staged 数据不入缓存/指纹/意象**:cache/rebuilder.js 只读 定稿;finalize-batch.test.js:92-100(AC7)断言批内 fingerprints=0、imagery_top=0。✓
+6. **收卷→序4 接得上**:is_volume_end 由 rebuilder.js:100 从 front matter `收卷` 派生;批次活动期 序3(待定稿/)优先于序4,不会 mid-batch 误触;finalize-batch 清空待定稿/后,收卷章入缓存 → 序4 卷复盘触发。链路通。✓
+7. **export 批次章导不到(符合预期)**:export/index.js:79 只扫 定稿/正文;批次章在 工作区/待定稿/,不导出——设计如此。✓
+8. **finalize-batch 入口硬校验 / 升序原子 / 中途失败停该章保留**:staging/index.js:517-556 + finalize-batch.test.js 全覆盖(AC1 逐字段一致、AC3 打回传染拒绝、中途坏包停该章、AC6 丢弃、--until)。✓
+
+---
+
+## 主靶小结(批次×手动流程交叉)
+
+| 手动例外流程 | 批次感知 | 结论 |
+|---|---|---|
+| goto-chapter 回退定稿 | **无** | E1:回退到起章下 → 孤儿批次 + 定稿缺口 |
+| 手动 finalize 单章 | **无** | E2:双计 + finalize-batch 卡死 |
+| relink 补登手改 | N/A | 只 add 定稿/大纲(detectors.js:99),不碰工作区批次,安全 |
+| retcon 吃书 | **无**(宽回滚) | 批次外,overlay 每次重读定稿,低风险;retcon 宽 restore/clean 属 C/D 区 |
+| impact 影响分析 | N/A | 只读 定稿/大纲,不涉批次,安全 |
+| 卷复盘(序4) | 序3 优先 | 在位正确(见核销 6) |
+| 体检 | 随 finalize-batch 尾跑 | staging/index.js:558-564,失败不阻断,在位 |
+
+根因收敛:**E1/E2 同源**——goto-chapter 与手动 finalize 命令缺「active 批次」互斥守卫;spec §8.1 假设二者职责不混但无强制。建议主会话优先探针这两条。

+ 102 - 0
.trellis/tasks/07-05-m1-m7-review/research/review-F.md

@@ -0,0 +1,102 @@
+# Review-F:M7 导出与迁移(export/migrate)
+
+- **审查区**: F 区(v7/src/export、v7/src/migrate、v7/src/commands/{export,migrate}、v7/docs/migration-guide.md)
+- **真源**: prd.md(本任务)、archive/2026-07/07-05-m7-export-migrate/design.md、research/v6-data-inventory.md
+- **Date**: 2026-07-05
+- **方式**: 逐文件精读 + 现场 node 探针复现(探针见下「探针记录」,用真 parser/serializer/rebuilder,非臆断)
+
+---
+
+## 探针记录(已复现,供主会话复核)
+
+**A 组 — book.yaml 手写拼接过真 parser(parseBookConfig=js-yaml)**
+
+| 书名输入 | 手写拼接 `书名: ${t}` 解析结果 |
+|---|---|
+| `Re:从零开始` / `诡秘之主:序曲` / `我的#笔记` / `我,钢铁侠` | ok(冒号不跟空格、`#`不跟空格、全角冒号都安全) |
+| `[快穿]反派` | **解析失败**:bad indentation of a mapping entry |
+| `*追读` | **解析失败**:unidentified alias |
+| `"引号"书` | **解析失败** |
+| `- 开局`(`-`+空格起首) | **解析失败** |
+| `诡秘:序曲 篇`(冒号+空格) | 手写=保留但语义危险;serializeYAML=修好 |
+| `12345` / `true` | 手写 → 漂移成数字 12345 / 布尔 true(类型错) |
+
+对照:把同样输入过 `serializeYAML`(防呆序列化器):`诡秘:序曲 篇`/`- 开局`/`12345`/`true` 全部 round-trip 正确;**但 `[快穿]反派`/`*追读` 仍失败**——见 F-2(序列化器本身缺口)。
+
+**B 组 — v6 一对多别名 → transform 名册 → rebuildCache**
+
+两实体 `张三`/`李四` 共享别名「小明」(v6 合法一对多消歧)→ transform 名册产出两行都带「小明」→ `rebuildCache` 返回 `ok=false errors=["别名冲突:「小明」同时指向「张三」和「李四」"]`。migrate index.js 据此 throw → **整迁移回滚**。
+
+**C 组 — 迁移章 front matter round-trip**
+
+`serializeFrontMatter({标题:'[番外]归乡'...})` → `parseFrontMatter` → **ok=false**(bad indentation);`标题:'*秘辛'` → **ok=false**(alias);`正常标题`/`危机:降临`(全角冒号)→ ok=true。
+
+**D 组 — 伏笔短题 slice(0,9) 截代理对**
+
+生僻字(U+20000 起,代理对)开头内容 `slice(0,9)` 末字符 = 孤高代理 `U+D840`;emoji 开头 = `U+D83D`。半个代理对进文件名。
+
+**RO-db 探针**:Node 24 下 `new DatabaseSync(dbPath,{readOnly:true})` 写被拦、无 -wal/-journal 残留(主路径干净);但 `{readonly:true}`(小写)不报错被静默忽略 → 说明未知 option 不校验,回退分支风险成立(F-6)。
+
+---
+
+## 候选清单([severity] file:line 问题 | 怀疑理由 | 建议探针 | 置信)
+
+**F-1 [P1] transform.js:41-48|book.yaml 手写模板拼接,书名/类型未过防呆序列化器**
+问题:book.yaml 由数组 join 手写(`书名: ${bookName}`、`类型: ${类型}`),值直取 v6 project.title/genre,绕开 serializeYAML。|怀疑理由:A 组已证——书名以 `[ * " -空格` 起首 → parseBookConfig 失败;纯数字/`true` → 类型漂移。各书内读取皆 `config.ok?…:默认` 优雅降级不崩,但后果=(a)自定义 book.yaml 设置静默回落默认、书名/类型在书内视图变默认;(b)session/index.js:57 scanRebuildBooks 只 push `cfg.ok` 的书 → books.jsonl 一旦扫描重建,这本书从书单消失。design §3.3 本要求「全部文件走防呆序列化器」,此处是唯一例外。|建议探针:全 migrate(书名`[快穿]反派`)后删 books.jsonl → loadBooks → 观察书消失。|置信:高(已探针)
+
+**F-2 [P1] storage/serializers/yaml-dialect.js:79-121|needsQuoting 不覆盖 YAML 指示符起首字符 → 迁移章/角色卡 front matter 解析炸**
+问题:needsQuoting 只处理数字/bool/null/含冒号/`#`起首/`-`起首/换行;漏掉以 `[ ] { } * & ! | > @ % 反引号 " '`(及 `?`+空格)起首的串。迁移章 `标题`、角色卡 `姓名`、含此类字符的值 serializeFrontMatter 后读不回。|怀疑理由:C 组已证。后果=rebuildCache scanChapters `parsed.ok` false → 该章静默不入缓存(migrate 仍报成功)、ChapterReader 读该章失败、export 该章/含该章范围失败、next 章号少计——静默部分内容丢失。根因在共享序列化器,亦波及 finalize 写章,建议与 A 区串审。|建议探针:已复现(C 组)。|置信:高(已探针)
+
+**F-3 [P1] read-v6.js:72-77,121-123 + transform.js:113-119|v6 一对多别名被复制到多实体 → migrate 整体硬失败**
+问题:v6 alias_index/aliases 支持一对多(同别名指多实体做消歧,inventory Q2)。read-v6 把该别名加到每个匹配实体,transform 名册各行都列该别名,rebuildCache scanEntities 第二次遇同别名即判「别名冲突」→ ROLLBACK → migrate index.js:69 throw → 整迁移回滚。|怀疑理由:B 组已证。含歧义别名的合法 v6 项目无法迁移且无绕过 flag;失败虽响亮且干净回滚(不腐坏),但整体阻断功能。|建议探针:已复现(B 组)。|置信:高(已探针)
+
+**F-4 [P2] transform.js:87|伏笔短题 slice(0,9) 截断代理对 → 文件名含孤代理**
+问题:`sanitizeFileName(fb.content).slice(0,9)` 按 UTF-16 码元切,前 9 位落在 emoji/生僻字(U+20000+)代理对中间时切出半个代理。|怀疑理由:D 组已证末字符为孤高代理 U+D840/U+D83D;写文件名时 Node/Windows 编码成 U+FFFD/WTF-8,产损坏或错配名。不崩,属保真/稳健性。|建议探针:fs.writeFile 该名后 readdir 比对。|置信:高(已探针)
+
+**F-5 [P2] migrate/index.js:138-148|sweepStaleTmp 会误删并行 migrate 的临时目录**
+问题:sweep 删所有 `.migrate-tmp-*` 前缀,不分 pid。两 migrate 并行同工作目录时,进程2 的 sweep 会 rm 进程1 正在写的 `.migrate-tmp-<pid1>`。|怀疑理由:进程1 文件被删 → git/rebuild 报错 → 失败回滚。单作者单 CLI 并发罕见但真实。|建议探针:两 node 进程近乎同时同目录跑 migrate。|置信:中(逻辑清晰、概率低)
+
+**F-6 [P2] read-v6.js:297-308|openReadOnly 回退到可写打开 → 违反 design §3.1「源零写入」/AC4**
+问题:`{readOnly:true}` 打开失败即 catch 回退 `new DatabaseSync(dbPath)`(可写)。源 db 被锁 / Node 版本不支持 readOnly 时以可写打开源 index.db,遇热日志会触发对源的恢复写入。|怀疑理由:RO 探针证主路径干净,但回退分支确可写。|建议探针:构造锁定/损坏 db 触发回退,查源目录 mtime/新增 -journal。|置信:中
+
+**F-7 [P2] transform.js:66,142,114-119|角色卡文件名 sanitize 但名册正名/关系用原名 → 缓存实体分裂或覆盖**
+问题:角色卡名 `sanitizeFileName(e.name).md`,名册正名列与关系用未净化 e.name。名字含 `<>:"/\|?*` 时:(a)缓存 scanEntities 用原名当 id、scanCharacters 用净化名当 id → 同角色两条实体;(b)两原名净化后相同(`甲:乙`/`甲/乙`→`甲_乙`)→ 后卡覆盖前卡;EntityWriter.updateCharacter 也会文件名与 name 不符找不到卡。|怀疑理由:逻辑推断,名字含非法字符才触发。|建议探针:两实体名 `甲:乙`/`甲/乙` 过 transform+rebuildCache 看 entities.id。|置信:中低(边缘输入)
+
+**F-8 [P2] migrate/index.js:86;read-v6.js:292,305;bin/webnovel-writer.js:147|报告/输出内插 err.message → 英文/机器码泄漏作者面**
+问题:失败信息内插 `${err.message}`,git/fs 错误多英文(ENOENT/EEXIST/git stderr),落进迁移错误行与报告 warning。|怀疑理由:logging 规范 §1.1 要求全中文人话无堆栈英文;属软违反(已知取舍非崩栈)。|建议探针:rename 目标被占触发 EEXIST 看文案。|置信:中(规范对照)
+
+**F-9 [P3] read-v6.js:184-208 + transform.js:122|卷目录嵌套超一层的章静默漏;自定义/英文实体类型键不出角色卡**
+问题:(a)scanChapters 只递归 `正文/第N卷/` 一层,`正文/第N卷/子目录/第NNN章.md` 静默跳过(无 warning)。(b)transform `if (e.type !== '角色') continue` 只给类型恰为「角色」的实体出卡;v6 用英文/自定义类型键(`character`/`组织`)时真角色只进名册不出卡。|怀疑理由:均非 v6 文档化常见布局;(a)静默无 warning、(b)保真缺口非崩。|建议探针:造两层嵌套正文 / entities_v3 用 `character` 键。|置信:中
+
+**F-10 [P3] util/filename.js:2-5|sanitizeFileName 不处理 Windows 保留设备名(CON/NUL/PRN/AUX/COM1-9/LPT1-9)**
+问题:书名/实体名恰为保留名时,migrate 目标目录(index.js:30-31)、角色卡(transform.js:142 → `CON.md`)在 Windows 上创建失败;角色卡写失败 → migrate throw → 整迁移回滚。export 文件名恒有 `第..章-`/`全书-` 前缀,碰撞低。|怀疑理由:Windows 保留名语义确定,输入边缘。|建议探针:Windows 上书名/实体名 `CON` 跑 migrate。|置信:中
+
+---
+
+## Export 侧核对(结论:多为 OK/REFUTED)
+
+- **范围有洞(goto 回退后重号)**:export/index.js:36-39 range/single 命中缺章 → 人话报错并列缺章号 + 提示当前定稿到第几章;`--all` 用 existing 只导已存在章(跳过洞,合理)。**OK**。
+- **2000 章全书导出内存**:parts 数组 + 全量 join,~十几 MB 字符串,远低于 V8 上限,顺序读无缓存。**REFUTED(非问题)**。
+- **单章文件名 double-sanitize**:title 取自 listFinalizedChapters 的磁盘文件名(已净化),再 sanitize 一次幂等无害。**OK**。
+- **--range 参数解析**:`/^(\d+)-(\d+)$/`,非法格式/布尔 flag 均落人话报错;起>止 报错。**OK**。
+- **保留名**:export 前缀恒定,风险归 migrate(见 F-10)。
+
+## migration-guide.md 逐条核对
+
+命令 `migrate <v6项目路径> [--dir=<目录名>]`、`--dir` 语义、目录已存在提示、state.json 坏/丢照迁文件面、失败自动回退、丢弃清单(债务台账/向量库)——均与实现一致。**细微**:guide 用 `npx webnovel-writer init` 与裸 `webnovel-writer migrate` 调用风格不一(皆装后可用,cosmetic);未提示 book.yaml 书名含 YAML 危险字符的注意事项(属 F-1 缺陷,非文档问题)。
+
+## 接力面核对(concern #2,结论:格式基本对得上)
+
+- 迁移时间线表头 `章|书内时间|一句话事件|在场` == TimelineWriter.TIMELINE_HEADERS;`第NN卷.md` pad2 一致。**OK**。
+- 迁移名册表头 `正名|别名|类型|首现章` == EntityWriter.ROSTER_HEADERS;upsert 按正名。**OK**。
+- 迁移伏笔 fm `强度/状态/开启章/预计收尾/最后推进章` + 正文 `## 描述/## 收尾计划/## 履历` == rebuildCache.scanThreads 与 ThreadLedgerWriter.appendHistory 读取假设;`_findThreadFile('伏笔-001').startsWith` 命中 `伏笔-001-短题.md`。**OK**。
+- 迁移章摘要为纯文本无 front matter == SummaryWriter 写法一致。**OK**。
+- 唯一接力风险来自 F-2/F-7:若章标题或实体名含 YAML 指示符/非法字符,缓存重建静默漏该章/分裂实体,后续 finalize 对该章的时间线追加/名册 upsert 不受影响,但该章/该实体在缓存视图缺失。
+
+---
+
+## Caveats / 未探针
+
+- F-5/F-6/F-7/F-9/F-10 未跑端到端 migrate 复现(逻辑级判定 + 部分组件探针);主会话可按「建议探针」补 CLI 复现定 CONFIRMED/PLAUSIBLE。
+- 未审 rebuildCache/finalize 全量正确性(属 A/B/C 区);F-2 根因在共享序列化器,跨区,已注明。
+- 真实「满血」v6 项目(含 index.db + .story-system)工作树内无 on-disk 样本,read-v6 的 db 分支仅逻辑级 + B 组最小 db 探针覆盖。

+ 113 - 0
.trellis/tasks/07-05-m1-m7-review/research/review-G.md

@@ -0,0 +1,113 @@
+# Research: review-G(命令壳 / bin / installer / host-shells + 测试脚手架掩盖审计)
+
+- **Query**: G 区审查——45+ 薄壳契约、bin 动态派发、installer/host-shells、测试脚手架掩盖
+- **Scope**: internal
+- **Date**: 2026-07-05
+- **审查范围**: v7/bin/webnovel-writer.js、v7/src/commands/(48 个文件)、v7/src/installer/、v7/src/host-shells/、v7/skills/webnovel-writer/SKILL.md、v7/roles/、v7/test/commands/_helper.js、test/fixtures/、scripts/pack-install-e2e.mjs
+
+每条格式:`[severity] file:line 一句话问题 | 怀疑理由 | 建议探针 | 置信`
+
+---
+
+## 候选清单(9 条)
+
+### G-2 [P1] migrate 名册类型「角色」vs 系统过滤「character」——迁移书角色实体全不可见
+`src/migrate/transform.js:117` + `src/cache/rebuilder.js:257`
+
+- **问题**: migrate 把 v6 的 `e.type`(中文「角色」)原样写进名册「类型」列;rebuilder `const type = row.类型 || 'character'` 原样入 `entities.type`,而 review 名册 / list-characters / report-book-stats / book-status 全部按 `WHERE type = 'character'`(英文)过滤 → 迁移书的角色以 `type='角色'` 入库,对上述四处全部不可见。
+- **怀疑理由**: `read-v6.js:85` `type: r.type || '角色'`、`transform.js:123` `if (e.type !== '角色') continue`、测试 `_v6.js:51` `INSERT entities VALUES('jiangyao','角色',...)` 三处确证 migrate 子系统用「角色」;消费端 `review/index.js:56`、`storage/adapters/EntityReader.js:107`、`commands/report-book-stats.js:10`、`prep/book-status.js:25` 全按 `'character'`;`rebuilder.js:38` scanEntities(plain INSERT,type=角色)先于 `:47` scanCharacters,后者 `:291` `ON CONFLICT(id) DO UPDATE` **未含 type** → 角色卡的 'character' 不会覆盖已入库的「角色」。sample-book fixture 用英文 `character` 恰好掩盖此漂移;`test/migrate/e2e.test.js` 只查 `COUNT(*) FROM chapters` 与 next 序号,从不查 entities.type / list-characters。
+- **建议探针**: 迁移 `tempV6Sqlite` 后 `SELECT type FROM entities WHERE id='江遥'`(预期 '角色');再进程内跑 list-characters(预期空数组)、report-book-stats(预期角色数 0)。
+- **置信**: 高
+
+### G-1 [P2/掩盖] gitBookCtx 建的仓库与真实建书结构不同(无 .gitignore / 未设 core.quotepath / 工作区被 git 跟踪)
+`test/commands/_helper.js:77-86`
+
+- **问题**: gitBookCtx = tempBookCtx(整拷 fixture,**无 .gitignore**)+ `git init` + `git add -A` + commit。真实建书 `persistCreateBook`(`state-machine/persist.js:60,66`)写 `.gitignore`(含 `.cache/`、`工作区/`)、`git.setQuotepathFalse()`、只 `git.add(written)`(仅 book.yaml/大纲/AGENTS/.gitignore)。→ 测试仓库把 `工作区/细纲.md` 纳入 git 跟踪、无 core.quotepath;真实仓库 工作区/ 未跟踪。
+- **怀疑理由**: 历史 P0「手动 git init 掩盖真实路径」同型。`goto-chapter` 的 `reset --hard`(`state-machine/flows/goto-chapter.js:57`)回退全部**已跟踪**文件——测试仓库(工作区被跟踪)会删/回退 工作区草稿,真实仓库(工作区未跟踪)保留,行为相反;M6「批次进行中 × goto-chapter」交叉的关键不变量(待定稿批次在 工作区/ 应免于 reset)无法用此脚手架如实验证。finalize 工作区清理、relink 的 status 前缀过滤在两种仓库形态语义不同。
+- **建议探针**: persistCreateBook 真实建书 vs gitBookCtx 各建一仓,`git ls-files | grep 工作区` 对比;在两种仓库跑 `goto-chapter --confirm` 后查 `工作区/细纲.md` 是否仍在。
+- **置信**: 高(结构差异已确证;对具体断言的掩盖需探针)
+
+### G-6 [P2] M6 六命令 + M7 export/migrate 无 spawn-bin 覆盖,只有进程内调用
+`test/integration/cli-main-loop.test.js:19` + `scripts/pack-install-e2e.mjs`
+
+- **问题**: stage-chapter / batch-status / finalize-batch / batch-reject / batch-restage / batch-discard / export / migrate 全部只有「进程内 run()/模块函数」测试,无一经真 bin 子进程 spawn → bin 层的 scope 解析、参数解析、`cache.close()` finally、exitCode 约定对这 8 命令未端到端验证。
+- **怀疑理由**: grep `spawn|execFile.*webnovel-writer|bin/webnovel-writer` 仅命中 finalize(git)/installer/cli-main-loop;`test/migrate/e2e.test.js:8-9` 走 `migrateV6`/`migrateCmd` 进程内;cli-main-loop 只覆盖 M2-M5 主循环(next/persist-book/list-books/prepare/mechanical-check/review-input/save-review/finalize/switch-book);pack-install-e2e 只覆盖 init/persist-book/next/update。
+- **建议探针**: 给某个 M6/M7 命令加一条 `execFile(BIN,[...])` 冒烟,验 scope='book'(export/batch)与 scope='workdir'(migrate)在真实 cwd 下解析 + exitCode。
+- **置信**: 高
+
+### G-4 [P2] finalize 工作区清理对 workspaceFiles 不挡 `..`,与 stage-chapter 守卫不一致
+`src/finalize/index.js:142-144` + `src/commands/finalize.js:20-24`
+
+- **问题**: finalize 只剥 `工作区/` 前缀(`finalize.js:21` `replace(/^工作区[\\/]/,'')`),不挡 `..`;`finalizeChapter` 直接 `fs.rm(path.join(repoPath,'工作区',wf))`,`wf='../定稿/正文/0001-x.md'` 会逃逸删到工作区外。stage-chapter(`staging/index.js:331` `if (!name.includes('..'))`)对同类清理有守卫,两路不一致。
+- **怀疑理由**: 清理在 commit 后跑,逃逸删定稿文件会被 git 记为 deleted(可 checkout 恢复),但属越界删除;payload 为作者/AI 可控。
+- **建议探针**: finalize payload 传 `workspaceFiles:['../定稿/正文/0001-...md']`,观察该定稿文件是否被删(`git status` 出现 deleted)。
+- **置信**: 中
+
+### G-3 [S] 名册「类型」列约定缺失——fixture 用英文 character、无角色/SKILL 指引规定该填什么
+`test/fixtures/sample-book/定稿/设定/名册.md:3-4` + `SKILL.md:39`
+
+- **问题**: fixture 名册 类型列填英文 `character`(作者面向中文文件里的机器味,历史模式10),系统内部 `entities.type` 词表确为英文 'character';但 grep roles/ 无名册类型指引、SKILL.md rosterUpserts 未定义「类型」取值 → 手写书由 AI 自由填,若循全中文基调填「角色」则同样触发 G-2 的不可见问题。EntityWriter.upsertRosterRow 原样写入不校验。
+- **怀疑理由**: `EntityWriter.js:8` ROSTER_HEADERS 含「类型」但无值域约束;消费端硬编码 'character'。
+- **建议探针**: 核对是否存在约束 rosterUpserts.类型 的 spec/schema;构造 类型='角色' 的手写名册跑 list-characters 看是否空。
+- **置信**: 中高
+
+### G-5 [S/P2] docs/migration-guide.md 既不发布也不 vendored,用户不可达(非断链)
+`v7/docs/migration-guide.md` + `package.json:12-19` + `src/installer/vendor.js:11`
+
+- **问题**: 面向作者的 v6→v7 迁移指引不在 npm `files` 白名单(['bin/','src/','roles/','skills/','adapters/','templates/'],无 docs/),也不在 vendored `RUNTIME_ENTRIES`(['bin','src','roles','package.json'])→ npx 装完、工作目录都拿不到;且 grep `migration-guide` 仅命中文件自身,无 SKILL/roles/migrate 输出引用(非断链)。migrate 输出指向的是 `工作区/迁移报告.md`(另一份,正常)。
+- **怀疑理由**: 内容明确面向终端作者(「给用过 v6 的作者」、逐步操作),但产品内不可交付。
+- **建议探针**: `npm pack` 解包看 docs/ 是否在 tar;确认该指引是否本就仅供仓库 README 引流。
+- **置信**: 高(不发布已确证;是否算问题取决于设计意图)
+
+### G-9 [P2] bin 命令名直接拼路径 import,`../` 可派发到 commands 目录外、报错人话降级
+`bin/webnovel-writer.js:106-108`
+
+- **问题**: `commandPath = path.join(__dirname,'../src/commands',`${command}.js`)` 后 import,command 含 `../` 经 path.join 归一后会 import 到 commands 外模块(如 `../runtime/locate` → src/runtime/locate.js);因目标无 `.run` 导出,落到 catch 的「执行命令时出错:mod.run is not a function」而非「未知命令」,人话降级。
+- **怀疑理由**: 无命令名白名单/正则校验。属操作者=用户本人的 CLI,非安全面;仅错误文案质量。
+- **建议探针**: `webnovel-writer ../runtime/locate` 看报错文案与 exitCode(预期非「未知命令」)。
+- **置信**: 中
+
+### G-7 [P2/低置信·跨D区] next 非 --json 路径直读 r.gitHealth.length,序1 可能未定义
+`src/commands/next.js:16`
+
+- **问题**: 非 `--json` 路径直接 `r.gitHealth.fixed.length`;若 `determineNextState` 在序1(空工作目录/无 book,allowNoBook 下 ctx.repoPath=null)返回不含 gitHealth 的对象,`next`(无 --json)会抛 "Cannot read length of undefined"。cli-main-loop 只测了空工作目录的 `--json` 路径(不碰 gitHealth)。
+- **怀疑理由**: shell 无防御;序1 是否恒设 gitHealth 属状态机(D 区)职责。
+- **建议探针**: 空 `.webnovel/` 工作目录里跑 `next`(不带 --json),看是否抛未定义。
+- **置信**: 低(取决于 determineNextState 返回形状)
+
+### G-8 [S/低] --help 硬编码「41 个读接口」计数无守卫,易漂移
+`bin/webnovel-writer.js:31`
+
+- **问题**: `精准读取接口(41 个,分布于 21 个命令)` 是硬编码文案,与实际读接口数无测试绑定(历史模式7 双源)。命令清单本身三方无漂移(48 个命令文件全部在 --help;SKILL 引用为合理子集);「21 个命令」已核对准确(read/list/report 类命令恰 21)。
+- **怀疑理由**: 数字硬编码。
+- **建议探针**: 数 read/list/report 命令的 --flag 组合是否=41。
+- **置信**: 低(仅文案)
+
+---
+
+## 复查在位(无新发现,历轮修复仍守)
+
+| 项 | 位置 | 结论 |
+|---|---|---|
+| bin cache.close() finally 覆盖 | `bin:150-152` | cache 仅在 book/workdir-book 分支赋值,ensureReady 抛错前已赋值,finally 恒关;workdir/anywhere/no-book 分支不建 cache。**在位** |
+| installer 新清单以旧为底防丢 | `installer/index.js:71` `newFiles={...manifest.files}` | **在位** |
+| persistCreateBook git init+quotepath+ensureIdentity+随手 commit | `state-machine/persist.js:68-77` | **在位**(P0-2) |
+| 薄壳契约「不碰 console/process」 | grep commands/ `console.|process.(exit\|argv\|cwd\|stdout\|stderr)` | 零命中,**全部合规** |
+| init/update 将 report 映射为 output | `init.js:13`/`update.js:13` | 正确映射,报告可打印。**无 G-hypothesis 的静默 bug** |
+| vendored 运行时含 migrate/export 新模块 | `vendor.js:11` RUNTIME_ENTRIES 含 'src' 整目录拷 | **天然含**,migrate/export/staging 均在 .webnovel/src/ |
+| host-shells drift/条件块渲染 | `host-shells/generate.js:26-34` | renderTemplate 处理 {{#if}}/{{#unless}}/{{var}};SKILL 新增自动模式/例外段无条件块(纯 {{cmd}} 插值),driftCheck 双跑字节比对确定性守住。**无漂移** |
+| scope 分级逐命令 | 见下 | 无「该 workdir 却缺省成 book」误判 |
+
+### scope 核对(显式导出 8 个,其余缺省 'book')
+- `anywhere`: init(装 .webnovel/ 前要能跑)✓
+- `workdir`: list-books / update / switch-book / session-context / **migrate**(建新书,用 ctx.workdir)✓
+- `workdir-or-book`: persist-book(建书时书目录还不存在)✓
+- `allowNoBook=true`: next(空工作目录报序1)✓
+- 缺省 'book'(含 export / 全部 batch 命令 / finalize / goto-chapter / relink / health-check / impact / 全部 read/list/report)——均需书仓库;从工作目录启动经 resolveRunContext 解析当前书(mode='workdir-book' 带 repoPath+cache),从书仓库根启动 mode='book',两处皆可跑。**无缺省成 book 却应 workdir 的命令**
+
+## Caveats / Not Found
+
+- G-2 的实际用户影响面取决于 v6 数据的 entity type 取值,但 migrate 子系统(read-v6/transform/_v6 fixture)与缓存层的 '角色' vs 'character' 契约错配是**代码级确定**,非依赖数据。scanCharacters ON CONFLICT 不改 type 已在 `rebuilder.js:289-298` 逐行确认。
+- G-1 的「掩盖」需探针确认具体哪些断言被误绿;结构差异本身已确证。goto-chapter × 批次的深层语义正确性(批次建立在被 reset 章之上)属 E 区(staging/state-machine),此处只报脚手架无法如实建模。
+- G-7 属 D 区(determineNextState 返回形状),仅从 G 区薄壳视角标出,未深入状态机。
+- 未发现空壳/占位实现;未发现薄壳越权碰缓存生命周期;bin `--version`/`--help`/未知命令三路 exitCode 与错误人话(不带栈)均合规。

+ 83 - 0
.trellis/tasks/07-05-m1-m7-review/review-m1-m7.md

@@ -0,0 +1,83 @@
+# M1-M7 全量 review 汇总报告(第三轮通审)
+
+- 日期:2026-07-05;方式:7 只读子代理分区精读(报告见 research/review-{A..G}.md)→ 主会话真实路径探针裁决(probe-m1-m7.mjs / probe-followup.mjs,全走 persistCreateBook 真建书,不用测试脚手架)
+- 候选总量 ~60;**探针裁决 CONFIRMED P1 × 11、PLAUSIBLE P1 × 1、P2 × 18、S × 10、REFUTED × 4**
+- 一句话结论:**主循环与 M6/M7 主路径是通的(前两轮的病没复发),但三类结构性缺口成立——防呆序列化器不防、批次与手动流程互不知情、迁移书的实体在缓存里隐身**。修复面收敛在 6 个文件左右。
+
+## 定性(对照前两轮)
+
+- M1-M4 轮病根「测试脚手架掩盖主循环」、M1-M5 轮病根「流程间接力没人测」——本轮复查:**历轮修复全部在位**(见 §历轮在位)。
+- 本轮新病根:**互斥不变量只写在 spec 没写进代码**(goto/手动 finalize 对批次零感知);**"防呆"序列化器有系统性漏网**(写出的 front matter 自己读不回);**fixture 恰好用了英文类型值,掩盖迁移链路的中英文断裂**(G-1/G-2,又一例脚手架掩盖)。
+
+---
+
+## P0(主循环断/丢数据,需立即修)
+
+无。
+
+## P1 CONFIRMED(探针复现,建议第一批修)
+
+| # | 问题 | 位置 | 探针证据 | 源报告 |
+|---|------|------|----------|--------|
+| R1 | **goto 回退×进行中批次 → 孤儿批次 + 定稿章号静默断档**:定稿 1-3、stage 4、goto 2 后 finalizeBatch 照跑,定稿变 [1,2,4] 缺 3;条目履历跨缺口 | flows/goto-chapter.js(零批次感知)+ staging/index.js finalizeBatch(无连续性校验) | P-1:`定稿=[1,2,4]` | E1/D2 |
+| R2 | **手动 finalize 已暂存章 → 叠加双计 + finalize-batch 卡死**:手动定稿 staged 章成功,overlay 总章数双计;finalizeBatch 撞 createThread「已存在」整批卡住 | commands/finalize.js(无批次守卫) | P-2:`finalizeBatch.ok=false 条目 伏笔-201 已存在` | E2 |
+| R3 | **persistRepair 一刀切 front matter 校验 → book.yaml/名册/时间线坏了锁死在序 0**:合法修复内容被「缺少 front matter 分隔符」拒绝,工具内无法自愈 | state-machine/persist.js:123 | P-3:合法 book.yaml 修复被拒 | D1 |
+| R4 | **finalize 清工作区遇目录 → 已 commit 却报失败**,文案谎称「已回滚、工作区原样保留」(commit 在 git log 里),且英文机器味泄漏;宿主重试会二次定稿 | finalize/index.js:142-144(fs.rm 无 recursive 无 try) | P-7b:`ok=false 但 ch(1) commit 在` | C1(探针后升 P1) |
+| R5 | **迁移书角色全不可见**:migrate 名册写中文「角色」,全链按英文 `'character'` 过滤(list-characters/审稿名册/近况/report) | migrate/transform.js vs rebuilder/各查询 | P-5:`entities.type=[角色,地点],list-characters 不含江遥` | G2 |
+| R6 | **别名分隔三源分裂**:名册用全角逗号/顿号分隔的别名 resolveAlias 全 MISS(EntityReader/rebuilder 只切 ASCII `,`,staging 切 `[,,、]`) | EntityReader.js:87、rebuilder.js:262、staging/index.js:220 | P-6:三别名全 MISS | A5 |
+| R7 | **防呆序列化器漏引**:空串→null、前后空格被裁、`0x1F/1e3/+5/~` 变数字/null;`[ * & " -空格` 起首值整份 front matter 解析炸 → 该章/卡静默不入缓存 | serializers/yaml-dialect.js:79-121 | A/F 双区独立复现(DRIFT/PARSEFAIL 表) | A1+F-2 |
+| R8 | **双引号分支不转义反斜杠**:值含 `\`+触发引号(Windows 路径)→ 解析失败整文件读不回 | serializers/yaml-dialect.js:65-71 | A 区复现 PARSEFAIL | A2 |
+| R9 | **book.yaml 手写拼接绕开序列化器**:书名 `[快穿]反派`/`*追读`/纯数字 → 解析失败/类型漂移 → 书内设置回落默认、books.jsonl 扫描重建时该书从书单消失 | migrate/transform.js:41-48 | F 区 A 组探针表 | F-1 |
+| R10 | **v6 一对多别名 → 迁移整体硬回滚无绕过**:含歧义别名的合法 v6 项目迁不进来(名册重复别名 → rebuild 判冲突 → ROLLBACK → throw) | migrate/read-v6.js + transform.js | F 区 B 组探针 | F-3 |
+| R11 | **表格序列化不转义 `\|`**:别名「刀疤\|老王」读回只剩「刀疤」,静默丢数据 | serializers/markdown-table.js:10 | A 区复现 | A4 |
+
+## P1 PLAUSIBLE(逻辑确凿、需故障注入才能复现)
+
+| # | 问题 | 位置 | 说明 |
+|---|------|------|------|
+| R12 | **atomic 出错回滚删掉未备份原文**:writeFile(tmp)/rename(full→backup) 先失败时 `existed=false`,restorePlan 误删从未动过的原文(Windows 长路径/文件占用触发) | storage/atomic.js:27-66 | 覆盖写场景(细纲/大纲)丢原文;建议修复时附故障注入回归测试 | A3 |
+
+## P2(18 条,第二批修/顺手修)
+
+- **Reader 键名冷热漂移**(A6,行为确凿):ChapterReader/ThreadLedgerReader 命中缓存返英文列名、降级返中文键——read-chapter/read-thread 输出形状随缓存冷热变,机器味进作者域。
+- **rebuild 静默无 warning**(B1 降级 + B-P2):坏 front matter 章/条目从缓存消失零信号(探针证 warnings 恒空)。注:完整「重抄本章」链被序 0(坏 YAML)与序 2(重号手改)兜住——B1 主链 REFUTED。
+- **名册缺「别名」列 → undefined.split 抛错 → 整次重建 ROLLBACK**(A13/A 接缝,违 §5.2 软失败)。
+- **scanThreads 吞 UNIQUE + 卷复盘写条目绕过 createThread 重号校验**(B2)。
+- **全角管道 `|` 表格整表解析失败静默变空**(A7);**GFM `\|` 不识别、多余列静默截断**(A8);**upsert 整行替换丢作者额外列**(A11)。
+- **_findThreadFile/SecretReader 无界前缀命中**(A9/C3):查不存在的 `伏笔-1` 命中 `伏笔-10-*.md`。
+- **角色卡文件名净化不同源**(A10/F-7):migrate 净化、EntityWriter/Reader 不净化 → 找不到卡/实体分裂。
+- **TimelineWriter.appendRow 不按章去重**(A12):重定稿同章时间线叠行。
+- **finalizeBatch 循环无 try-catch**(E3):转正后 rm/meta 失败裸栈 + 批次.json 失步 → 重跑撞「已存在」。
+- **stageChapter 覆盖重暂存清理失败报假失败**(E4);**--until 后建议误导「继续写下一章」**(E5);**批次.json 损坏重建把打回空目录变死「受影响」行**(E6)。
+- **finalize 工作区清理不挡 `..`**(C2/G-4,与 stage-chapter 守卫不一致)。
+- **迁移短题 slice(0,9) 截代理对**(F-4);**sweepStaleTmp 误删并行 migrate 的 tmp**(F-5);**openReadOnly 回退可写打开违反源只读**(F-6);**err.message 英文泄漏作者面**(F-8/B 区 ensureReady)。
+- **gitBookCtx 仓库形态失真**(G-1:无 .gitignore、工作区被跟踪——goto×批次类交叉在既有测试里无法如实验证);**M6/M7 新命令零 bin spawn 覆盖**(G-6)。
+- **陈旧 imagery_top 跨重建保留**(B):改源刷缓存后到下次体检前,备料/机检吃旧高频意象(提醒性)。
+
+## S(10 条,spec 漂移/卫生)
+
+内层 catch 架空重建事务意图(B-S,结构性);批次.json 持久形态与 §8.1 声明字段漂移(E7);弱钩谓词四处双写(E8);停止判据 front matter vs payload 可分叉(E9,契约提示);extractUnknownFields 死代码(A14);readRange 死代码+契约错位(A15);book-config 死校验(A16);appendUnderSection 子串匹配偏脆(A17);migration-guide 不发布不 vendored 用户不可达(G-5);--help「41 个」硬编码计数(G-8);名册「类型」列取值无约定(G-3,R5 的规范面)。
+
+## REFUTED(探针驳回,记录防复审)
+
+1. B1 主链「静默截断→next 重抄本章」:坏 YAML 被序 0 拦、重号文件被序 2 手改检测拦(P-4/P-4b),只余「无 warning」卫生问题。
+2. C1 初版路径(workspaceFiles 带「工作区/」前缀):join 后指向不存在路径被 force 吞,无害——真实缺陷在正确路径的目录输入(已升 R4)。
+3. export 2000 章内存、goto 后范围有洞、单章 double-sanitize(F 区核对,非问题)。
+4. staged 并入机检 known 把提醒变阻断(C 区核对:反而抑制假阻断)。
+
+## 历轮修复在位复查(AC4,全数通过)
+
+- M1-M4 P0/P1:定稿后刷缓存(finalize:139)、重建单事务+别名冲突 ROLLBACK、临时库替换、回滚收窄到 written、schema 严格布尔/critical 阻断——在位 ✓
+- M1-M5 三条 P1:卷复盘 `vol(NN)` commit(persist.js:104)、六处改源自刷缓存(finalize/goto/retcon/卷复盘/修复回写/relink,主会话独立 grep 与 D 区双验证)、relink 执行通道——在位 ✓(覆盖面缺口见 P2「文风/book.yaml 不在 relink 范围」→ 记 D 区候选 3)
+- M5.5 确定性、M6 staged 不入缓存/指纹(AC7)、M6 before 防倒灌/容差同源/收卷接序 4——在位 ✓
+
+## CLEAN 面(七区核销汇总)
+
+薄壳契约全合规、bin cache.close 全分支、SQL 全参数化、六改源全刷缓存、体检只吃定稿、无批次零行为变化、finalize-batch 入口硬校验/升序原子/中途保留、export/migrate 接力面表头逐一对得上、drift 确定性、migration-guide 与实现一致。
+
+## 修复 backlog 建议(供裁决,非本任务范围)
+
+- **第一批(互斥守卫 + 序列化器,R1-R4/R7/R8/R12)**:goto/手动 finalize 加 active 批次拦截(人话指引先 finalize-batch 或 batch-discard);persistRepair 按文件类型分派校验器(book.yaml→parseBookConfig、名册/时间线→parseMarkdownTable);finalize 清理 `rm(recursive:true)` + 包 try 只记 warning(commit 后的清理失败不得改写 ok);yaml-dialect needsQuoting 补指示符/空串/空格/数字变体 + 转义反斜杠;atomic existed 时序修正。
+- **第二批(迁移链,R5/R6/R9/R10/R11)**:类型值统一(建议迁移写英文 machine 值或全链改中文——需定规范 G-3);别名分隔抽单一 splitAliases;book.yaml 走 serializeYAML;一对多别名降级为「主实体保留+其余进待校对」;表格单元格转义。
+- **第三批**:P2 清单按模块顺手修 + gitBookCtx 对齐真实建书 + 新命令补 bin spawn 用例。
+- spec 侧:G-3 名册类型值约定、E7 批次.json 字段口径、B-S 事务边界条款化。

+ 26 - 0
.trellis/tasks/07-05-m1-m7-review/task.json

@@ -0,0 +1,26 @@
+{
+  "id": "m1-m7-review",
+  "name": "m1-m7-review",
+  "title": "M1-M7 全量 review",
+  "description": "",
+  "status": "in_progress",
+  "dev_type": null,
+  "scope": null,
+  "package": null,
+  "priority": "P2",
+  "creator": "codex",
+  "assignee": "codex",
+  "createdAt": "2026-07-05",
+  "completedAt": null,
+  "branch": null,
+  "base_branch": "v7",
+  "worktree_path": null,
+  "commit": null,
+  "pr_url": null,
+  "subtasks": [],
+  "children": [],
+  "parent": null,
+  "relatedFiles": [],
+  "notes": "",
+  "meta": {}
+}