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

feat(v7): M6 P1——staging 批次模块、stage-chapter/batch-status、threadCreates 缺口修复

lingfengQAQ преди 1 ден
родител
ревизия
3a017e66b4

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

@@ -69,10 +69,14 @@ if (!command || command === '--help') {
   console.log('')
   console.log('状态机 / 例外流程(M3):')
   console.log('  next [--json]                           继续:状态机判定下一步(--json 出完整 DTO)')
-  console.log('  health-check                            体检:悬了太久/条目活跃率/连续弱钩,报告落工作区(文体项随 M5.5)')
+  console.log('  health-check                            体检:账面 + 高频意象/句式/指纹漂移/缺时间锚点,报告落工作区')
   console.log('  impact <关键词>                          影响分析:哪些章建立在这个事实上(已发布/未发布)')
   console.log('  goto-chapter <章号> [--confirm]          回到第N章(先备份再回滚,作者不碰 git)')
   console.log('  relink --message=<一句话说明>            补登手改:定稿/大纲 未登记改动入档(fix(手改))')
+  console.log('')
+  console.log('自动模式(M6,连写按批次定稿):')
+  console.log('  stage-chapter <章号> --payload=<json>    暂存一章进待定稿批次(不 commit),返回停止判定')
+  console.log('  batch-status [--json]                   批次全貌:各章状态/审稿摘要/停止条件')
   process.exit(0)
 }
 

+ 58 - 0
v7/src/commands/batch-status.js

@@ -0,0 +1,58 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { readBatch, judgeStop } from '../staging/index.js'
+
+/**
+ * batch-status [--json]:待定稿批次全貌——各章状态、审稿单问题摘要、停止条件判定。
+ * 作者批量过稿的呈报数据面;--json 给宿主程序消费。
+ * 契约:纯返回 {ok, output?, error?}。
+ */
+export async function run(args, options, ctx) {
+  const batch = await readBatch(ctx.repoPath)
+  if (!batch.exists) {
+    return { ok: false, error: '没有进行中的待定稿批次(连写用 stage-chapter 暂存后再看)' }
+  }
+
+  const 停止 = await judgeStop(ctx, batch)
+  const 章 = []
+  for (const row of batch.章列表) {
+    const 审稿 = await reviewDigest(ctx.repoPath, row.目录)
+    章.push({ 章号: row.章号, 标题: row.标题, 状态: row.状态, 审稿: 审稿 })
+  }
+
+  if (options.json) {
+    return {
+      ok: true,
+      output: JSON.stringify({ 起章: batch.起章, 章: 章, 停止, warnings: batch.warnings }, null, 2),
+    }
+  }
+
+  const 尾章 = batch.章列表[batch.章列表.length - 1].章号
+  const lines = [`待定稿批次:第 ${batch.起章}-${尾章} 章,共 ${章.length} 章。`, '']
+  for (const c of 章) {
+    lines.push(`- 第 ${c.章号} 章《${c.标题}》|${c.状态}|${c.审稿}`)
+  }
+  lines.push('')
+  if (停止.stop) {
+    lines.push('停止条件已命中:')
+    for (const reason of 停止.reasons) lines.push(`- ${reason}`)
+  } else {
+    lines.push('停止条件未命中,批次还能继续写。')
+  }
+  for (const w of batch.warnings) lines.push(`提醒:${w}`)
+  return { ok: true, output: lines.join('\n') }
+}
+
+async function reviewDigest(repoPath, dirName) {
+  try {
+    const md = await fs.readFile(
+      path.join(repoPath, '工作区', '待定稿', dirName, '审稿单.md'),
+      'utf8'
+    )
+    const m = md.match(/共 (\d+) 个问题:(\d+) 阻断/)
+    if (m) return `审稿:${m[1]} 个问题(${m[2]} 阻断)`
+    return '审稿:已附审稿单'
+  } catch {
+    return '审稿:无审稿单(打回待重写或工件缺失)'
+  }
+}

+ 35 - 0
v7/src/commands/stage-chapter.js

@@ -0,0 +1,35 @@
+import { stageChapter } from '../staging/index.js'
+import { readJsonInput } from '../util/json-input.js'
+
+/**
+ * stage-chapter <章号> --payload=<定稿包json路径>:自动模式暂存一章到 工作区/待定稿/
+ * (不定稿不 commit)。前置:本章两审已完成(工作区/审稿.md 在)。payload 同 finalize。
+ * 返回携带停止条件判定,宿主按「继续/停」走批次循环。
+ * 契约:纯返回 {ok, output?, error?}。
+ */
+export async function run(args, options, ctx) {
+  const chapterNum = parseInt(args[0], 10)
+  if (isNaN(chapterNum)) return { ok: false, error: '章号必须是数字' }
+
+  const spec = await readJsonInput(ctx, options.payload ?? options.file, 'payload')
+  if (!spec.ok) return { ok: false, error: spec.error }
+  const payload = spec.data
+  if (Array.isArray(payload.workspaceFiles)) {
+    payload.workspaceFiles = payload.workspaceFiles.map((f) =>
+      String(f).replace(/^工作区[\\/]/, '')
+    )
+  }
+
+  const r = await stageChapter(ctx, { chapterNum, payload })
+  if (!r.ok) return { ok: false, error: r.error }
+
+  const lines = [`第 ${chapterNum} 章已暂存(批内 ${r.staged} 章,未入档——批量定稿时才 commit)。`]
+  if (r.停止.stop) {
+    lines.push('批次到此为止,转人工:')
+    for (const reason of r.停止.reasons) lines.push(`- ${reason}`)
+    lines.push('运行 batch-status 看批次全貌,交作者批量过稿。')
+  } else {
+    lines.push('停止条件未命中,可继续写批内下一章(运行 next 判定)。')
+  }
+  return { ok: true, output: lines.join('\n') }
+}

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

@@ -27,6 +27,7 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
     frontMatter,
     body = '',
     summary = null,
+    threadCreates = [],
     threadUpdates = [],
     characterUpdates = [],
     rosterUpserts = [],
@@ -62,6 +63,13 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
     }
 
     const tlw = new ThreadLedgerWriter(repoPath)
+    // 新开条目先建档(开启类声明的入档执行体),再处理更新——同一章可"埋下并写首条履历"
+    for (const t of threadCreates) {
+      const r = await tlw.createThread(t)
+      if (!r.ok) throw new Error(r.error)
+      stageFiles.push(r.filePath)
+      rollbackFiles.push(r.filePath)
+    }
     for (const t of threadUpdates) {
       if (t.updates) {
         const r = await tlw.updateThread(t.id, t.updates)

+ 3 - 7
v7/src/mechanical-check/index.js

@@ -3,15 +3,11 @@ import path from 'node:path'
 import { parseFrontMatter } from '../storage/parsers/front-matter.js'
 import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
 import { parseThreadDeclarations, VERBS, OPENING_VERBS } from '../util/thread-declarations.js'
-import { styleMetrics } from '../style-stats/index.js'
+import { styleMetrics, AVG_SENTENCE_LEN_TOLERANCE, SENTENCE_VARIANCE_TOLERANCE } from '../style-stats/index.js'
 
 // front matter 章档案必填字段(§4.1 机器消费部分)
 const REQUIRED_FM = ['章号', '标题', '卷', '字数', '章定位', '钩子', '情绪定位']
 
-// 句式偏离容差(vs 基线指纹;硬编码合理默认,候选只提醒不拦截)
-const AVG_LEN_TOLERANCE = 0.3
-const VARIANCE_TOLERANCE = 0.5
-
 /**
  * 机检:零 token 可计数项(D2 七项 + 条目变动形式检查,spec 0.9 §8 第 5 步)。
  * 不过关(pass=false)= 存在阻断 issue。新专名/信息差关键词/高频意象/句式偏离只出候选
@@ -256,7 +252,7 @@ async function checkStyleDeviation(body, cache, candidates) {
   const m = styleMetrics(body)
   if (base.avg_sentence_length > 0) {
     const dev = (m.平均句长 - base.avg_sentence_length) / base.avg_sentence_length
-    if (Math.abs(dev) >= AVG_LEN_TOLERANCE) {
+    if (Math.abs(dev) >= AVG_SENTENCE_LEN_TOLERANCE) {
       candidates.push({
         type: '句式偏离',
         value: '平均句长',
@@ -266,7 +262,7 @@ async function checkStyleDeviation(body, cache, candidates) {
   }
   if (base.sentence_length_variance > 0) {
     const dev = (m.句长方差 - base.sentence_length_variance) / base.sentence_length_variance
-    if (Math.abs(dev) >= VARIANCE_TOLERANCE) {
+    if (Math.abs(dev) >= SENTENCE_VARIANCE_TOLERANCE) {
       candidates.push({
         type: '句式偏离',
         value: '句长方差',

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

@@ -0,0 +1,508 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseFrontMatter } from '../storage/parsers/front-matter.js'
+import { serializeFrontMatter } from '../storage/serializers/front-matter.js'
+import { parseThreadDeclarations, OPENING_VERBS } from '../util/thread-declarations.js'
+import { sanitizeFileName } from '../util/filename.js'
+import { writeAtomicBatch } from '../storage/atomic.js'
+import { finalizeChapter } from '../finalize/index.js'
+import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
+import {
+  styleMetrics,
+  AVG_SENTENCE_LEN_TOLERANCE,
+  SENTENCE_VARIANCE_TOLERANCE,
+} from '../style-stats/index.js'
+import { runHealthCheck } from '../health-check/index.js'
+
+/**
+ * staging:待定稿批次(自动模式,spec §8.1)。批次真源 = 工作区/待定稿/ 下的文件,
+ * 本模块是其唯一读写点;不依赖也不写缓存——批次进行中删缓存重建,叠加视图输出不变。
+ * 每章目录三件套:草稿.md / 定稿包.json(完整 finalize payload,转正时原样喂 finalizeChapter)/ 审稿单.md。
+ */
+
+const BATCH_DIR = path.join('工作区', '待定稿')
+const META = '批次.json'
+
+export const 章状态 = { 待审收: '待审收', 打回: '打回', 受影响: '受影响' }
+
+/**
+ * 读批次元数据(含与实际章目录的对账)。
+ * @returns {Promise<{exists: boolean, 起章: number, 章列表: Array<{章号,标题,状态,目录}>, warnings: string[]}>}
+ */
+export async function readBatch(repoPath) {
+  const empty = { exists: false, 起章: 0, 章列表: [], warnings: [] }
+  const dir = path.join(repoPath, BATCH_DIR)
+  let entries
+  try {
+    entries = await fs.readdir(dir, { withFileTypes: true })
+  } catch {
+    return empty
+  }
+  const dirNames = entries.filter((e) => e.isDirectory() && /^\d{4}-/.test(e.name)).map((e) => e.name)
+
+  let meta = null
+  try {
+    meta = JSON.parse(await fs.readFile(path.join(dir, META), 'utf8'))
+    if (!Array.isArray(meta?.章列表)) meta = null
+  } catch {
+    meta = null
+  }
+
+  const warnings = []
+  const rows = []
+  const seen = new Set()
+  if (meta) {
+    for (const r of meta.章列表) {
+      if (!Number.isInteger(r.章号)) continue
+      const dirExists = dirNames.includes(r.目录)
+      // 打回章目录内容已清(工件按设计移除),元数据行保留;其余状态目录缺失 = 数据不一致,如实丢行
+      if (!dirExists && r.状态 !== 章状态.打回) {
+        warnings.push(`批次记录里第 ${r.章号} 章的目录(${r.目录})不见了,已从批次中移除该记录。`)
+        continue
+      }
+      rows.push({ 章号: r.章号, 标题: r.标题 || '', 状态: r.状态 || 章状态.受影响, 目录: r.目录 })
+      seen.add(r.章号)
+    }
+    for (const d of dirNames) {
+      const num = parseInt(d.slice(0, 4), 10)
+      if (seen.has(num)) continue
+      if (rows.some((r) => r.目录 === d)) continue
+      warnings.push(`发现批次记录之外的章目录 ${d},按「受影响」纳入,请重审后再定稿。`)
+      rows.push(await rowFromDir(repoPath, d, warnings))
+    }
+  } 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 (!rows.length) return empty
+  rows.sort((a, b) => a.章号 - b.章号)
+  return { exists: true, 起章: rows[0].章号, 章列表: rows, warnings }
+}
+
+async function rowFromDir(repoPath, dirName, warnings) {
+  const num = parseInt(dirName.slice(0, 4), 10)
+  let 标题 = dirName.slice(5)
+  try {
+    const parsed = parseFrontMatter(
+      await fs.readFile(path.join(repoPath, BATCH_DIR, dirName, '草稿.md'), 'utf8')
+    )
+    if (parsed.ok && parsed.data.标题) 标题 = parsed.data.标题
+  } catch {
+    warnings.push(`章目录 ${dirName} 里的草稿读不出来。`)
+  }
+  return { 章号: num, 标题, 状态: 章状态.受影响, 目录: dirName }
+}
+
+function metaFile(rows) {
+  return {
+    path: path.join(BATCH_DIR, META),
+    content: JSON.stringify({ 章列表: rows }, null, 2),
+  }
+}
+
+/**
+ * 叠加视图的事实包:staged 章正文/档案 + 预登记(条目/名册/角色/时间线/信息差)。
+ * 全部来自批次文件,供备料/审稿输入/机检合并;无批次时 exists=false 且各集合为空。
+ */
+export async function stagedFacts(repoPath) {
+  const batch = await readBatch(repoPath)
+  const facts = {
+    exists: batch.exists,
+    chapters: [],
+    threads: new Map(),
+    newEntities: new Set(),
+    newAliases: new Set(),
+    characterUpdates: new Map(),
+    timelineRows: [],
+    secretWrites: [],
+    总字数: 0,
+    warnings: [...batch.warnings],
+  }
+  if (!batch.exists) return facts
+
+  for (const row of batch.章列表) {
+    const dirP = path.join(repoPath, BATCH_DIR, row.目录)
+
+    let fm = {}
+    let body = ''
+    try {
+      const parsed = parseFrontMatter(await fs.readFile(path.join(dirP, '草稿.md'), 'utf8'))
+      fm = parsed.ok ? parsed.data : {}
+      body = parsed.body || ''
+    } catch (err) {
+      if (row.状态 !== 章状态.打回) facts.warnings.push(`批内第 ${row.章号} 章草稿读取失败:${err.message}`)
+      continue
+    }
+    facts.chapters.push({ 章号: row.章号, 标题: row.标题, 状态: row.状态, frontMatter: fm, body })
+    facts.总字数 += Number(fm.字数) || 0
+
+    // 声明推导条目事实(与机检/审稿共用解析器,不双写)
+    const { declarations } = parseThreadDeclarations(fm)
+    for (const d of declarations) {
+      if (OPENING_VERBS.has(d.verb)) {
+        facts.threads.set(d.id, {
+          id: d.id,
+          type: d.type,
+          状态: '进行',
+          开启章: row.章号,
+          最后推进章: row.章号,
+          新开: true,
+        })
+      } else {
+        const t = facts.threads.get(d.id) || { id: d.id, type: d.type, 新开: false }
+        t.最后推进章 = row.章号
+        facts.threads.set(d.id, t)
+      }
+    }
+
+    // 定稿包预登记
+    let payload = null
+    try {
+      payload = JSON.parse(await fs.readFile(path.join(dirP, '定稿包.json'), 'utf8'))
+    } catch (err) {
+      facts.warnings.push(`批内第 ${row.章号} 章定稿包读取失败:${err.message}`)
+      continue
+    }
+    for (const t of payload.threadCreates || []) {
+      if (!t?.id) continue
+      const cur = facts.threads.get(t.id) || { id: t.id, type: String(t.id).split('-')[0], 新开: true }
+      cur.新开 = true
+      cur.状态 = t.frontMatter?.状态 || '进行'
+      cur.开启章 = t.frontMatter?.开启章 ?? row.章号
+      cur.最后推进章 = cur.最后推进章 ?? row.章号
+      facts.threads.set(t.id, cur)
+    }
+    for (const t of payload.threadUpdates || []) {
+      if (!t?.id) continue
+      const cur = facts.threads.get(t.id) || { id: t.id, type: String(t.id).split('-')[0], 新开: false }
+      if (t.updates?.状态) cur.状态 = t.updates.状态
+      cur.最后推进章 = t.updates?.最后推进章 ?? row.章号
+      facts.threads.set(t.id, cur)
+    }
+    for (const r of payload.rosterUpserts || []) {
+      if (r?.正名) facts.newEntities.add(r.正名)
+      for (const a of splitAliases(r?.别名)) facts.newAliases.add(a)
+    }
+    for (const c of payload.characterUpdates || []) {
+      if (!c?.name) continue
+      facts.characterUpdates.set(c.name, { ...(facts.characterUpdates.get(c.name) || {}), ...c.updates })
+    }
+    for (const tr of payload.timelineRows || []) facts.timelineRows.push(tr)
+    for (const s of payload.secretWrites || []) facts.secretWrites.push(s)
+  }
+  return facts
+}
+
+function splitAliases(v) {
+  if (Array.isArray(v)) return v.filter(Boolean).map((s) => String(s).trim()).filter(Boolean)
+  if (typeof v === 'string') return v.split(/[,,、]/).map((s) => s.trim()).filter(Boolean)
+  return []
+}
+
+/**
+ * 暂存一章(自动模式的"第 7-8 步推迟"):校验 → 落批次目录 → 清本章工作区文件 → 停止判定。
+ * 前置:本章两审已跑(工作区/审稿.md 在)——自动模式砍的是逐章问作者,不是逐章审。
+ */
+export async function stageChapter(ctx, { chapterNum, payload }) {
+  const { repoPath, cache } = ctx
+  try {
+    if (!Number.isInteger(chapterNum)) return { ok: false, error: '章号必须是整数' }
+    if (!payload || typeof payload !== 'object') return { ok: false, error: '缺少定稿包 payload' }
+    if (payload.chapterNum !== undefined && payload.chapterNum !== chapterNum) {
+      return { ok: false, error: `章号不一致:命令行是 ${chapterNum},payload 里是 ${payload.chapterNum}` }
+    }
+    if (!payload.frontMatter || !payload.frontMatter.标题) {
+      return { ok: false, error: '缺少章档案或标题' }
+    }
+
+    const batch = await readBatch(repoPath)
+    const existing = batch.章列表.find((r) => r.章号 === chapterNum)
+    if (!existing) {
+      const rows = await cache.query('SELECT MAX(chapter_num) AS m FROM chapters')
+      const maxChapter = rows[0]?.m || 0
+      const expected = (batch.exists ? batch.章列表[batch.章列表.length - 1].章号 : maxChapter) + 1
+      if (chapterNum !== expected) {
+        return { ok: false, error: `批内章号必须连续:下一章应是第 ${expected} 章,收到的是第 ${chapterNum} 章` }
+      }
+    }
+
+    let 审稿单 = ''
+    try {
+      审稿单 = await fs.readFile(path.join(repoPath, '工作区', '审稿.md'), 'utf8')
+    } catch {
+      return {
+        ok: false,
+        error: '工作区没有审稿单(审稿.md)——自动模式每章仍要过机检与两审,先完成两审再暂存',
+      }
+    }
+
+    const 标题 = payload.frontMatter.标题
+    const dirName = `${String(chapterNum).padStart(4, '0')}-${sanitizeFileName(标题)}`
+    const rel = (f) => path.join(BATCH_DIR, dirName, f)
+
+    const rows = batch.章列表.filter((r) => r.章号 !== chapterNum)
+    rows.push({ 章号: chapterNum, 标题, 状态: 章状态.待审收, 目录: dirName })
+    rows.sort((a, b) => a.章号 - b.章号)
+
+    await writeAtomicBatch(repoPath, [
+      { path: rel('草稿.md'), content: serializeFrontMatter(payload.frontMatter, payload.body || '') },
+      { path: rel('定稿包.json'), content: JSON.stringify({ ...payload, chapterNum }, null, 2) },
+      { path: rel('审稿单.md'), content: 审稿单 },
+      metaFile(rows),
+    ])
+
+    // 覆盖重暂存且标题变了:旧目录成孤儿,写成后清掉
+    if (existing && existing.目录 !== dirName) {
+      await fs.rm(path.join(repoPath, BATCH_DIR, existing.目录), { recursive: true, force: true })
+    }
+
+    // 清本章工作区单章文件(已搬进批次目录;评审报告是本章两审产物一并清)
+    const clears = new Set(['细纲.md', '本章写作材料.md', '草稿-A.md', '审稿.md', '评审报告'])
+    for (const wf of payload.workspaceFiles || []) {
+      const name = String(wf).replace(/^工作区[\\/]/, '')
+      if (!name.includes('..')) clears.add(name)
+    }
+    for (const wf of clears) {
+      await fs.rm(path.join(repoPath, '工作区', wf), { recursive: true, force: true })
+    }
+
+    const 停止 = await judgeStop(ctx, await readBatch(repoPath))
+    return { ok: true, 章号: chapterNum, staged: rows.length, 停止, error: '' }
+  } catch (err) {
+    return { ok: false, error: `暂存第 ${chapterNum} 章失败:${err.message}` }
+  }
+}
+
+/**
+ * 停止条件四件套(spec §8.1,零 token):写满 / 收卷或卷纲耗尽 / 连续无条目变动 / 批次质检不过线。
+ * @returns {Promise<{stop: boolean, reasons: string[]}>}
+ */
+export async function judgeStop(ctx, batch) {
+  const { repoPath, cache } = ctx
+  if (!batch?.exists) return { stop: false, reasons: [] }
+  const reasons = []
+  const config = await new BookConfigReader(repoPath).read()
+  const cfg = config.ok ? config.data : {}
+  const 批次大小 = cfg.连写批次大小 || 8
+  const 无变动上限 = cfg.连写无条目变动上限 || 3
+
+  const facts = await stagedFacts(repoPath)
+  const staged = facts.chapters
+
+  if (staged.length >= 批次大小) {
+    reasons.push(`批次写满(${staged.length}/${批次大小} 章),该交作者批量过稿了`)
+  }
+
+  const last = staged[staged.length - 1]
+  if (last && (last.frontMatter.收卷 === '是' || last.frontMatter.收卷 === true)) {
+    reasons.push(`第 ${last.章号} 章声明收卷,批次到此为止(随后进卷复盘)`)
+  } else if (last) {
+    const vol = Number(last.frontMatter.卷) || 1
+    const nn = String(vol).padStart(2, '0')
+    try {
+      await fs.access(path.join(repoPath, '大纲', '卷纲', `第${nn}卷.md`))
+    } catch {
+      reasons.push(`第 ${vol} 卷没有卷纲,连写到此为止——绝不让模型裸奔编纲,请先补卷纲`)
+    }
+  }
+
+  let 连续无变动 = 0
+  for (let i = staged.length - 1; i >= 0; i--) {
+    if (parseThreadDeclarations(staged[i].frontMatter).declarations.length) break
+    连续无变动++
+  }
+  if (连续无变动 >= 无变动上限) {
+    reasons.push(`连续 ${连续无变动} 章无条目变动(上限 ${无变动上限})——剧情可能在原地踏步`)
+  }
+
+  const baseline = await readBaselineFingerprint(cache)
+  const q = judgeBatchQuality(staged, baseline, cfg)
+  if (!q.过线) reasons.push(...q.原因)
+
+  return { stop: reasons.length > 0, reasons }
+}
+
+async function readBaselineFingerprint(cache) {
+  try {
+    const rows = await cache.query(
+      'SELECT avg_sentence_length, sentence_length_variance FROM fingerprints WHERE is_baseline = 1 ORDER BY chapter_range_end DESC LIMIT 1'
+    )
+    return rows[0] || null
+  } catch {
+    return null
+  }
+}
+
+/**
+ * 批次质检("体检不过线"的批次落点,纯函数零 IO):弱钩尾 / 缺书内时间 / 句式 vs 基线。
+ * @param {Array<{章号, frontMatter, body}>} staged 升序 staged 章
+ * @param {{avg_sentence_length, sentence_length_variance}|null} baselineFp 基线指纹行(无则句式判据跳过)
+ * @param {object} config book.yaml 配置
+ * @returns {{过线: boolean, 原因: string[]}}
+ */
+export function judgeBatchQuality(staged, baselineFp, config = {}) {
+  const 原因 = []
+  if (!staged?.length) return { 过线: true, 原因 }
+
+  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++
+    else break
+  }
+  if (weak >= 弱上限) 原因.push(`批内连续 ${weak} 章弱钩(上限 ${弱上限}),追读力要垮,先人工过一眼节奏`)
+
+  const 缺锚 = staged.filter((c) => !String(c.frontMatter.书内时间 ?? '').trim()).map((c) => c.章号)
+  if (缺锚.length) 原因.push(`第 ${缺锚.join('、')} 章缺「书内时间」——时间锚点从第 1 章起强制`)
+
+  if (baselineFp && baselineFp.avg_sentence_length > 0) {
+    const m = styleMetrics(staged.map((c) => c.body).join('\n\n'))
+    const devLen = Math.abs(m.平均句长 - baselineFp.avg_sentence_length) / baselineFp.avg_sentence_length
+    if (devLen >= AVG_SENTENCE_LEN_TOLERANCE) {
+      原因.push(
+        `批内平均句长 ${m.平均句长.toFixed(1)} 字 vs 基线 ${baselineFp.avg_sentence_length.toFixed(1)} 字,偏了 ${Math.round(devLen * 100)}%——文风可能漂了`
+      )
+    }
+    if (baselineFp.sentence_length_variance > 0) {
+      const devVar =
+        Math.abs(m.句长方差 - baselineFp.sentence_length_variance) / baselineFp.sentence_length_variance
+      if (devVar >= SENTENCE_VARIANCE_TOLERANCE) {
+        原因.push(
+          `批内句长方差 ${m.句长方差.toFixed(1)} vs 基线 ${baselineFp.sentence_length_variance.toFixed(1)},偏了 ${Math.round(devVar * 100)}%——句子长短的节奏感变了`
+        )
+      }
+    }
+  }
+
+  return { 过线: 原因.length === 0, 原因 }
+}
+
+/**
+ * 打回第 K 章:K 置「打回」并清空该章工件(重写后 stage 覆盖),K+1..N 置「受影响」(工件保留,需重审)。
+ */
+export async function rejectFrom(repoPath, chapterNum) {
+  const batch = await readBatch(repoPath)
+  if (!batch.exists) return { ok: false, error: '没有进行中的待定稿批次' }
+  const row = batch.章列表.find((r) => r.章号 === chapterNum)
+  if (!row) {
+    const 尾 = batch.章列表[batch.章列表.length - 1].章号
+    return { ok: false, error: `第 ${chapterNum} 章不在批次内(批内是第 ${batch.起章}-${尾} 章)` }
+  }
+  const 受影响 = []
+  for (const r of batch.章列表) {
+    if (r.章号 === chapterNum) r.状态 = 章状态.打回
+    else if (r.章号 > chapterNum) {
+      r.状态 = 章状态.受影响
+      受影响.push(r.章号)
+    }
+  }
+  // 打回章工件清空(目录保留,元数据行保留——重写后 stage-chapter 覆盖)
+  const dirP = path.join(repoPath, BATCH_DIR, row.目录)
+  for (const f of ['草稿.md', '定稿包.json', '审稿单.md']) {
+    await fs.rm(path.join(dirP, f), { force: true })
+  }
+  await writeAtomicBatch(repoPath, [metaFile(batch.章列表)])
+  return { ok: true, 打回: chapterNum, 受影响, error: '' }
+}
+
+/**
+ * 受影响章重审完成后回到「待审收」(重审通道:重跑两审 → save-review → 本函数搬审稿单更新状态)。
+ */
+export async function restageReview(repoPath, chapterNum) {
+  const batch = await readBatch(repoPath)
+  if (!batch.exists) return { ok: false, error: '没有进行中的待定稿批次' }
+  const row = batch.章列表.find((r) => r.章号 === chapterNum)
+  if (!row) return { ok: false, error: `第 ${chapterNum} 章不在批次内` }
+  if (row.状态 === 章状态.打回) {
+    return { ok: false, error: `第 ${chapterNum} 章是打回章,要重写后用 stage-chapter 重新暂存,不能只重审` }
+  }
+  let 审稿单 = ''
+  try {
+    审稿单 = await fs.readFile(path.join(repoPath, '工作区', '审稿.md'), 'utf8')
+  } catch {
+    return { ok: false, error: '工作区没有新审稿单(审稿.md),先重跑两审' }
+  }
+  await writeAtomicBatch(repoPath, [
+    { path: path.join(BATCH_DIR, row.目录, '审稿单.md'), content: 审稿单 },
+  ])
+  row.状态 = 章状态.待审收
+  await writeAtomicBatch(repoPath, [metaFile(batch.章列表)])
+  await fs.rm(path.join(repoPath, '工作区', '审稿.md'), { force: true })
+  await fs.rm(path.join(repoPath, '工作区', '评审报告'), { recursive: true, force: true })
+  return { ok: true, 章号: chapterNum, error: '' }
+}
+
+/**
+ * 批量定稿:全部(或 --until 前的)章须为「待审收」;升序逐章 finalizeChapter——
+ * 每章独立原子 commit,中途失败停在该章、已入档保留(原子性按章保留)。
+ */
+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 已入档 = []
+  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) {
+      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 })
+    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: '' }
+}
+
+/** 整批丢弃:删工作区批次(未入 git,定稿零变化)。破坏性操作,宿主必须先经作者确认。 */
+export async function discardBatch(repoPath) {
+  const batch = await readBatch(repoPath)
+  if (!batch.exists) return { ok: false, 章数: 0, error: '没有进行中的待定稿批次' }
+  await fs.rm(path.join(repoPath, BATCH_DIR), { recursive: true, force: true })
+  return { ok: true, 章数: batch.章列表.length, error: '' }
+}

+ 1 - 6
v7/src/storage/adapters/ChapterWriter.js

@@ -1,6 +1,7 @@
 import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { serializeFrontMatter } from '../serializers/front-matter.js'
+import { sanitizeFileName } from '../../util/filename.js'
 
 /**
  * ChapterWriter:写新章到定稿(M2 定稿流程调用)。
@@ -8,12 +9,6 @@ import { serializeFrontMatter } from '../serializers/front-matter.js'
 
 let backupCounter = 0
 
-/** 文件名净化:Windows 非法字符 <>:"/\|?* 与控制字符替成 _(标题本体不改,只净化文件名)。 */
-function sanitizeFileName(title) {
-  const s = String(title).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/\s+/g, ' ').trim()
-  return s || '未命名'
-}
-
 /** 临时挪走同章旧文件(标题可能不同),避免 scanChapters 撞 PRIMARY KEY(P0-3a)。 */
 async function backupOldChapterFiles(dir, chapterNum, safeTitle) {
   const prefix = `${String(chapterNum).padStart(4, '0')}-`

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

@@ -3,9 +3,11 @@ import path from 'node:path'
 import { parseFrontMatter } from '../parsers/front-matter.js'
 import { serializeFrontMatter } from '../serializers/front-matter.js'
 import { appendUnderSection } from '../../util/markdown.js'
+import { THREAD_TYPES } from '../../util/thread-declarations.js'
+import { sanitizeFileName } from '../../util/filename.js'
 
 /**
- * ThreadLedgerWriter:更新三类条目 front matter、追加履历(M2 定稿流程调用)。
+ * ThreadLedgerWriter:新开条目、更新三类条目 front matter、追加履历(定稿流程调用)。
  */
 export class ThreadLedgerWriter {
   constructor(repoPath, cache = null) {
@@ -13,6 +15,42 @@ export class ThreadLedgerWriter {
     this.cache = cache
   }
 
+  /**
+   * 新开条目(开启类声明的入档执行体,spec §5/§8 第 8 步"条目文件由定稿创建")。
+   * @param {{id: string, frontMatter?: object, body?: string, 短题?: string}} spec
+   * @returns {Promise<{ok: boolean, filePath: string, error: string}>}
+   */
+  async createThread({ id, frontMatter = {}, body = '', 短题 = '' } = {}) {
+    const idStr = String(id || '')
+    const type = idStr.split('-')[0]
+    if (!THREAD_TYPES.includes(type) || !/^\S+-\d+$/.test(idStr)) {
+      return {
+        ok: false,
+        filePath: '',
+        error: `新条目编号「${idStr || '(空)'}」不合法,应为 伏笔-NNN / 悬念-NNN / 感情线-NNN`,
+      }
+    }
+    try {
+      const dir = path.join(this.repoPath, '大纲', type)
+      let existing = []
+      try {
+        existing = await fs.readdir(dir)
+      } catch {
+        // 目录不存在,首个条目
+      }
+      if (existing.some((f) => f === `${idStr}.md` || f.startsWith(`${idStr}-`))) {
+        return { ok: false, filePath: '', error: `条目 ${idStr} 已存在,开新条目须用新编号` }
+      }
+      await fs.mkdir(dir, { recursive: true })
+      const fileName = 短题 ? `${idStr}-${sanitizeFileName(短题)}.md` : `${idStr}.md`
+      const filePath = path.join(dir, fileName)
+      await fs.writeFile(filePath, serializeFrontMatter(frontMatter, body), 'utf8')
+      return { ok: true, filePath, error: '' }
+    } catch (err) {
+      return { ok: false, filePath: '', error: `新开条目 ${idStr} 失败:${err.message}` }
+    }
+  }
+
   /**
    * 更新条目 front matter(合并 updates,保留正文与未在 updates 中的字段)。
    * @param {string} threadId 如 "伏笔-001"

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

@@ -36,6 +36,7 @@ export function parseBookConfig(yamlString) {
     关键章稿数: 3,
     自动确认细纲: false,
     连写批次大小: 8,
+    连写无条目变动上限: 3,
   }
 
   // 合并默认值(只覆盖 undefined 的字段)

+ 4 - 0
v7/src/style-stats/index.js

@@ -8,6 +8,10 @@
 export const IMAGERY_MIN_COUNT = 10
 export const IMAGERY_MIN_CHAPTERS = 3
 
+// 句式偏离容差(单章机检 vs 基线、批次质检共用同一口径,不双写)
+export const AVG_SENTENCE_LEN_TOLERANCE = 0.3
+export const SENTENCE_VARIANCE_TOLERANCE = 0.5
+
 const MIN_GRAM = 4
 const MAX_GRAM = 8
 const TTR_WINDOW = 1000

+ 5 - 0
v7/src/util/filename.js

@@ -0,0 +1,5 @@
+/** 文件名净化:Windows 非法字符 <>:"/\|?* 与控制字符替成 _(标题本体不改,只净化文件名)。 */
+export function sanitizeFileName(title) {
+  const s = String(title).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/\s+/g, ' ').trim()
+  return s || '未命名'
+}

+ 76 - 0
v7/test/commands/stage-chapter.test.js

@@ -0,0 +1,76 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { run as stageChapterCmd } from '../../src/commands/stage-chapter.js'
+import { run as batchStatusCmd } from '../../src/commands/batch-status.js'
+import { repoCtx } from './_helper.js'
+
+const 定稿章 = (num) =>
+  `---\n章号: ${num}\n标题: 第${num}章\n卷: 1\n书内时间: 春月初${num}\n字数: 100\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫\n---\n\n第${num}章正文。`
+
+const files = {
+  'book.yaml': 'spec_version: "7.0"\n书名: 测\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n连写批次大小: 3\n',
+  '定稿/正文/0001-第1章.md': 定稿章(1),
+  '定稿/正文/0002-第2章.md': 定稿章(2),
+  '大纲/卷纲/第01卷.md': '# 第01卷\n后三章追查黑影。\n',
+  '工作区/审稿.md': '# 第 3 章审稿单\n\n> 完整两审模式。\n> 共 2 个问题:0 阻断。\n',
+}
+
+test('stage-chapter 命令:--payload 文件暂存 + 人话输出;batch-status 出全貌', async () => {
+  const { ctx, cleanup } = await repoCtx(null, files)
+  try {
+    const payload = {
+      frontMatter: {
+        章号: 3,
+        标题: '追影',
+        卷: 1,
+        书内时间: '春月初三',
+        字数: 100,
+        章定位: '推进',
+        钩子: '危机钩-强',
+        情绪定位: '铺垫',
+        伏笔: ['推进 伏笔-001'],
+      },
+      body: '林晚追到后山。',
+      workspaceFiles: ['工作区/细纲.md'],
+    }
+    const payloadPath = path.join(ctx.repoPath, '定稿包-3.json')
+    await fs.writeFile(payloadPath, JSON.stringify(payload), 'utf8')
+
+    const r = await stageChapterCmd(['3'], { payload: payloadPath }, ctx)
+    assert.equal(r.ok, true, r.error)
+    assert.match(r.output, /第 3 章已暂存/)
+    assert.match(r.output, /未入档/)
+    assert.match(r.output, /可继续写批内下一章/)
+
+    const s = await batchStatusCmd([], {}, ctx)
+    assert.equal(s.ok, true, s.error)
+    assert.match(s.output, /第 3-3 章,共 1 章/)
+    assert.match(s.output, /待审收/)
+    assert.match(s.output, /审稿:2 个问题(0 阻断)/)
+    assert.match(s.output, /停止条件未命中/)
+
+    const j = await batchStatusCmd([], { json: true }, ctx)
+    const data = JSON.parse(j.output)
+    assert.equal(data.章[0].章号, 3)
+    assert.equal(data.停止.stop, false)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('stage-chapter 命令:章号不是数字 / 缺 payload / 无批次 status 友好错', async () => {
+  const { ctx, cleanup } = await repoCtx(null, files)
+  try {
+    assert.equal((await stageChapterCmd(['abc'], {}, ctx)).ok, false)
+    const noPayload = await stageChapterCmd(['3'], {}, ctx)
+    assert.equal(noPayload.ok, false)
+    assert.match(noPayload.error, /--payload/)
+    const st = await batchStatusCmd([], {}, ctx)
+    assert.equal(st.ok, false)
+    assert.match(st.error, /没有进行中的待定稿批次/)
+  } finally {
+    await cleanup()
+  }
+})

+ 90 - 0
v7/test/finalize/finalize.test.js

@@ -7,6 +7,7 @@ import { promisify } from 'node:util'
 import { finalizeChapter } from '../../src/finalize/index.js'
 import { createGit } from '../../src/finalize/git.js'
 import { gitBookCtx } from '../commands/_helper.js'
+import { mechanicalCheck } from '../../src/mechanical-check/index.js'
 
 const execFileAsync = promisify(execFile)
 
@@ -155,3 +156,92 @@ test('finalizeChapter 改同章标题后断电:旧章恢复,新章不残留'
     await cleanup()
   }
 })
+
+// —— 新开条目入档(M6 P1.0:threadCreates,补 M2 起「埋下」无入档通道的缺口)——
+
+const 新条目 = () => ({
+  id: '伏笔-002',
+  短题: '黑影来历',
+  frontMatter: { 强度: '中', 状态: '进行', 开启章: 3, 最后推进章: 3 },
+  body: '## 描述\n黑影的来历。\n\n## 收尾计划\n第二卷揭晓。\n\n## 履历\n- 第3章:埋下——黑影首次现身\n',
+})
+
+test('finalizeChapter threadCreates:埋下建档→缓存可见→下一章推进机检零误报(接力)', async () => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    const p = payload()
+    p.frontMatter.伏笔 = ['推进 伏笔-001', '埋下 伏笔-002']
+    p.threadCreates = [新条目()]
+    const r = await finalizeChapter(ctx, p)
+    assert.equal(r.ok, true, r.error)
+
+    const f = await fs.readFile(path.join(ctx.repoPath, '大纲/伏笔/伏笔-002-黑影来历.md'), 'utf8')
+    assert.match(f, /状态: 进行/)
+    assert.match(f, /## 履历/)
+    const rows = await ctx.cache.query("SELECT id, status FROM threads WHERE id = '伏笔-002'")
+    assert.equal(rows.length, 1, '定稿刷缓存后新条目必须可见')
+
+    // 接力:下一章草稿声明「推进 伏笔-002」,机检不得误判「不存在」
+    const draft = [
+      '---',
+      '章号: 4',
+      '标题: 追影',
+      '卷: 1',
+      '字数: 40',
+      '章定位: 推进',
+      '钩子: 危机钩-强',
+      '情绪定位: 铺垫',
+      '伏笔:',
+      '  - 推进 伏笔-002',
+      '---',
+      '林晚循着黑影的踪迹一路追到后山,夜风里那道身影再次出现,她悄悄握紧了袖中的短刀。',
+    ].join('\n')
+    const draftPath = path.join(ctx.repoPath, '工作区', '草稿-A.md')
+    await fs.mkdir(path.dirname(draftPath), { recursive: true })
+    await fs.writeFile(draftPath, draft, 'utf8')
+    const mc = await mechanicalCheck(ctx, { chapterNum: 4, draftPath })
+    assert.equal(mc.ok, true, mc.error)
+    assert.deepEqual(
+      mc.issues.filter((i) => i.check === '条目变动'),
+      [],
+      JSON.stringify(mc.issues)
+    )
+  } finally {
+    await cleanup()
+  }
+})
+
+test('finalizeChapter threadCreates 撞已有编号 → 整体失败并干净回滚', async () => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    const git = createGit(ctx.repoPath)
+    const before = await git.revCount()
+    const p = payload()
+    p.threadCreates = [{ ...新条目(), id: '伏笔-001' }] // sample-book 已有
+    const r = await finalizeChapter(ctx, p)
+    assert.equal(r.ok, false)
+    assert.match(r.error, /已存在/)
+    assert.equal(await git.revCount(), before)
+    const { stdout } = await execFileAsync(
+      'git',
+      ['status', '--porcelain', '--', '定稿', '大纲'],
+      { cwd: ctx.repoPath, encoding: 'utf8' }
+    )
+    assert.equal(stdout.trim(), '')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('finalizeChapter threadCreates 断电回滚:新条目文件不残留', async () => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    const p = payload()
+    p.threadCreates = [新条目()]
+    const r = await finalizeChapter(ctx, p, { faultAfterWrite: true })
+    assert.equal(r.ok, false)
+    await assert.rejects(() => fs.access(path.join(ctx.repoPath, '大纲/伏笔/伏笔-002-黑影来历.md')))
+  } finally {
+    await cleanup()
+  }
+})

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

@@ -0,0 +1,325 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import {
+  readBatch,
+  stagedFacts,
+  stageChapter,
+  judgeStop,
+  judgeBatchQuality,
+  rejectFrom,
+  restageReview,
+  discardBatch,
+  章状态,
+} from '../../src/staging/index.js'
+import { repoCtx } from '../commands/_helper.js'
+
+// —— fixture:已有 2 章定稿的书,批次从第 3 章起 ——
+
+const 定稿章 = (num) =>
+  [
+    '---',
+    `章号: ${num}`,
+    `标题: 第${num}章`,
+    '卷: 1',
+    `书内时间: 1023春月初${num}`,
+    '字数: 100',
+    '章定位: 推进',
+    '钩子: 危机钩-强',
+    '情绪定位: 铺垫',
+    '---',
+    '',
+    `第${num}章正文。`,
+  ].join('\n')
+
+function bookFiles({ 批次大小 = 3, 卷纲 = true } = {}) {
+  const files = {
+    'book.yaml': `spec_version: "7.0"\n书名: 连写测试\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n连写批次大小: ${批次大小}\n`,
+    '定稿/正文/0001-第1章.md': 定稿章(1),
+    '定稿/正文/0002-第2章.md': 定稿章(2),
+  }
+  if (卷纲) files['大纲/卷纲/第01卷.md'] = '# 第01卷\n第三章到第五章:追查黑影。\n'
+  return files
+}
+
+const 审稿单 = (num) => `# 第 ${num} 章审稿单\n\n> 完整两审模式(事实审查/编辑审各自独立上下文)。\n> 共 0 个问题:0 阻断。\n`
+
+function mkPayload(num, { 标题 = `第${num}章`, 钩子 = '危机钩-强', 书内时间 = `1023夏月初${num}`, 伏笔 = null, 收卷 = null, body = null } = {}) {
+  const frontMatter = {
+    章号: num,
+    标题,
+    卷: 1,
+    视角: '林晚',
+    字数: 100,
+    章定位: '推进',
+    钩子,
+    情绪定位: '铺垫',
+  }
+  if (书内时间) frontMatter.书内时间 = 书内时间
+  if (伏笔) frontMatter.伏笔 = 伏笔
+  if (收卷) frontMatter.收卷 = 收卷
+  return {
+    frontMatter,
+    body: body ?? `林晚在第${num}章继续追查,线索一路指向后山。夜色渐深,她的脚步没有停。`,
+    summary: `第${num}章摘要。`,
+    commitLines: {},
+    workspaceFiles: [],
+  }
+}
+
+async function stage(ctx, num, opts) {
+  await fs.mkdir(path.join(ctx.repoPath, '工作区'), { recursive: true })
+  await fs.writeFile(path.join(ctx.repoPath, '工作区', '审稿.md'), 审稿单(num), 'utf8')
+  return stageChapter(ctx, { chapterNum: num, payload: mkPayload(num, opts) })
+}
+
+test('stage 首章:三件套落位、meta 记录、工作区清、停止未命中', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles())
+  try {
+    const r = await stage(ctx, 3, { 伏笔: ['推进 伏笔-001'] })
+    assert.equal(r.ok, true, r.error)
+    assert.equal(r.停止.stop, false, JSON.stringify(r.停止))
+
+    const dir = path.join(ctx.repoPath, '工作区', '待定稿', '0003-第3章')
+    for (const f of ['草稿.md', '定稿包.json', '审稿单.md']) {
+      await fs.access(path.join(dir, f))
+    }
+    const batch = await readBatch(ctx.repoPath)
+    assert.equal(batch.exists, true)
+    assert.deepEqual(
+      batch.章列表.map((x) => [x.章号, x.状态]),
+      [[3, 章状态.待审收]]
+    )
+    // 本章工作区文件已清(审稿单搬进批次目录)
+    await assert.rejects(() => fs.access(path.join(ctx.repoPath, '工作区', '审稿.md')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('stage 跳章拒绝、重章覆盖(改标题清旧目录)', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles())
+  try {
+    const skip = await stage(ctx, 5)
+    assert.equal(skip.ok, false)
+    assert.match(skip.error, /连续.*第 3 章/)
+
+    assert.equal((await stage(ctx, 3)).ok, true)
+    const again = await stage(ctx, 3, { 标题: '改名章' })
+    assert.equal(again.ok, true, again.error)
+    const batch = await readBatch(ctx.repoPath)
+    assert.equal(batch.章列表.length, 1)
+    assert.equal(batch.章列表[0].标题, '改名章')
+    await assert.rejects(() => fs.access(path.join(ctx.repoPath, '工作区', '待定稿', '0003-第3章')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('stage 无审稿单拒绝,零写入', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles())
+  try {
+    const r = await stageChapter(ctx, { chapterNum: 3, payload: mkPayload(3) })
+    assert.equal(r.ok, false)
+    assert.match(r.error, /审稿/)
+    await assert.rejects(() => fs.access(path.join(ctx.repoPath, '工作区', '待定稿')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('停止条件:批次写满', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles({ 批次大小: 3 }))
+  try {
+    assert.equal((await stage(ctx, 3, { 伏笔: ['推进 伏笔-001'] })).停止.stop, false)
+    assert.equal((await stage(ctx, 4, { 伏笔: ['推进 伏笔-001'] })).停止.stop, false)
+    const r = await stage(ctx, 5, { 伏笔: ['推进 伏笔-001'] })
+    assert.equal(r.停止.stop, true)
+    assert.ok(r.停止.reasons.some((x) => x.includes('写满')), JSON.stringify(r.停止))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('停止条件:收卷声明终止批次', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles({ 批次大小: 8 }))
+  try {
+    const r = await stage(ctx, 3, { 收卷: '是', 伏笔: ['推进 伏笔-001'] })
+    assert.equal(r.停止.stop, true)
+    assert.ok(r.停止.reasons.some((x) => x.includes('收卷')), JSON.stringify(r.停止))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('停止条件:卷纲耗尽(当前卷无卷纲文件)', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles({ 批次大小: 8, 卷纲: false }))
+  try {
+    const r = await stage(ctx, 3, { 伏笔: ['推进 伏笔-001'] })
+    assert.equal(r.停止.stop, true)
+    assert.ok(r.停止.reasons.some((x) => x.includes('卷纲')), JSON.stringify(r.停止))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('停止条件:连续 3 章无条目变动(默认上限),有变动章重置计数', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles({ 批次大小: 8 }))
+  try {
+    assert.equal((await stage(ctx, 3)).停止.stop, false)
+    assert.equal((await stage(ctx, 4)).停止.stop, false)
+    const r5 = await stage(ctx, 5)
+    assert.equal(r5.停止.stop, true)
+    assert.ok(r5.停止.reasons.some((x) => x.includes('无条目变动')), JSON.stringify(r5.停止))
+    // 第 5 章重暂存并带条目变动 → 尾部计数归零,不再停
+    const r5b = await stage(ctx, 5, { 伏笔: ['推进 伏笔-001'] })
+    assert.equal(r5b.停止.stop, false, JSON.stringify(r5b.停止))
+  } finally {
+    await cleanup()
+  }
+})
+
+// —— 批次质检(纯函数)——
+
+const 质检章 = (num, { 钩子 = '危机钩-强', 书内时间 = 'x', body = '正文。' } = {}) => ({
+  章号: num,
+  frontMatter: { 钩子, 书内时间 },
+  body,
+})
+
+test('批次质检:弱钩尾达上限', async () => {
+  const staged = [1, 2, 3].map((n) => 质检章(n, { 钩子: '悬念钩-弱' }))
+  const q = judgeBatchQuality(staged, null, { 连续弱钩上限: 3 })
+  assert.equal(q.过线, false)
+  assert.ok(q.原因.some((x) => x.includes('弱钩')), JSON.stringify(q))
+  // 尾部只有 2 章弱钩 → 过线
+  const staged2 = [质检章(1), 质检章(2, { 钩子: '悬念钩-弱' }), 质检章(3, { 钩子: '悬念钩-弱' })]
+  assert.equal(judgeBatchQuality(staged2, null, { 连续弱钩上限: 3 }).过线, true)
+})
+
+test('批次质检:缺书内时间列出章号', async () => {
+  const q = judgeBatchQuality([质检章(3), 质检章(4, { 书内时间: '' })], null, {})
+  assert.equal(q.过线, false)
+  assert.ok(q.原因.some((x) => x.includes('第 4 章') && x.includes('书内时间')), JSON.stringify(q))
+})
+
+test('批次质检:句式 vs 基线 29% 不停 31% 停;无基线跳过', async () => {
+  const 字池 =
+    '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔龙师火帝鸟官人皇始制文字乃服衣裳推位让国有虞陶唐吊民伐罪周发殷汤坐朝问道垂拱平章爱育黎首臣伏戎羌遐迩一体率宾归王鸣凤在竹白驹食场'
+  const bodyOf = (lengths) => {
+    let pos = 0
+    const parts = []
+    for (const n of lengths) {
+      parts.push(字池.slice(pos, pos + n))
+      pos += n
+    }
+    return parts.join('。') + '。'
+  }
+  const base = { avg_sentence_length: 10, sentence_length_variance: 0 }
+  const at29 = [质检章(3, { body: bodyOf([12, 13, 13, 13, 13, 13, 13, 13, 13, 13]) })]
+  assert.equal(judgeBatchQuality(at29, base, {}).过线, true, '偏 29% 不停')
+  const at31 = [质检章(3, { body: bodyOf([13, 13, 13, 13, 13, 13, 13, 13, 13, 14]) })]
+  const q = judgeBatchQuality(at31, base, {})
+  assert.equal(q.过线, false, '偏 31% 停')
+  assert.ok(q.原因.some((x) => x.includes('平均句长')), JSON.stringify(q))
+  assert.equal(judgeBatchQuality(at31, null, {}).过线, true, '无基线句式判据跳过')
+})
+
+// —— 打回 / 重审 / 丢弃 / 对账 ——
+
+test('rejectFrom:K 打回清工件,K+1..N 受影响;restageReview 回待审收', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles({ 批次大小: 8 }))
+  try {
+    for (const n of [3, 4, 5]) await stage(ctx, n, { 伏笔: ['推进 伏笔-001'] })
+    const r = await rejectFrom(ctx.repoPath, 4)
+    assert.equal(r.ok, true, r.error)
+    assert.deepEqual(r.受影响, [5])
+    const batch = await readBatch(ctx.repoPath)
+    assert.deepEqual(
+      batch.章列表.map((x) => [x.章号, x.状态]),
+      [
+        [3, 章状态.待审收],
+        [4, 章状态.打回],
+        [5, 章状态.受影响],
+      ]
+    )
+    // 打回章工件已清
+    await assert.rejects(() =>
+      fs.access(path.join(ctx.repoPath, '工作区', '待定稿', '0004-第4章', '草稿.md'))
+    )
+    // 打回章不能只重审
+    const bad = await restageReview(ctx.repoPath, 4)
+    assert.equal(bad.ok, false)
+    assert.match(bad.error, /重写/)
+    // 受影响章重审:新审稿单 → 待审收
+    await fs.writeFile(path.join(ctx.repoPath, '工作区', '审稿.md'), 审稿单(5), 'utf8')
+    const ok5 = await restageReview(ctx.repoPath, 5)
+    assert.equal(ok5.ok, true, ok5.error)
+    assert.equal((await readBatch(ctx.repoPath)).章列表.find((x) => x.章号 === 5).状态, 章状态.待审收)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('批次.json 损坏 → 按目录重建,保守标记受影响', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles())
+  try {
+    await stage(ctx, 3, { 伏笔: ['推进 伏笔-001'] })
+    await fs.writeFile(path.join(ctx.repoPath, '工作区', '待定稿', '批次.json'), '{{{', 'utf8')
+    const batch = await readBatch(ctx.repoPath)
+    assert.equal(batch.exists, true)
+    assert.equal(batch.章列表[0].状态, 章状态.受影响)
+    assert.ok(batch.warnings.some((w) => w.includes('批次.json')), JSON.stringify(batch.warnings))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('discardBatch:整批丢弃,工作区批次消失', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles())
+  try {
+    await stage(ctx, 3)
+    const r = await discardBatch(ctx.repoPath)
+    assert.equal(r.ok, true)
+    assert.equal(r.章数, 1)
+    assert.equal((await readBatch(ctx.repoPath)).exists, false)
+    await assert.rejects(() => fs.access(path.join(ctx.repoPath, '工作区', '待定稿')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('stagedFacts:声明与定稿包合并出条目/名册/时间线/信息差事实', async () => {
+  const { ctx, cleanup } = await repoCtx(null, bookFiles({ 批次大小: 8 }))
+  try {
+    await fs.mkdir(path.join(ctx.repoPath, '工作区'), { recursive: true })
+    await fs.writeFile(path.join(ctx.repoPath, '工作区', '审稿.md'), 审稿单(3), 'utf8')
+    const p = mkPayload(3, { 伏笔: ['埋下 伏笔-009'] })
+    p.threadCreates = [
+      { id: '伏笔-009', 短题: '古钟', frontMatter: { 强度: '中', 状态: '进行', 开启章: 3 }, body: '## 描述\n古钟。\n' },
+    ]
+    p.rosterUpserts = [{ 正名: '古钟长老', 别名: '钟叟, 老钟', 类型: 'character', 首现章: 3 }]
+    p.characterUpdates = [{ name: '林晚', updates: { 最后变更章: 3 } }]
+    p.timelineRows = [{ volumeNum: 1, row: { 章: 3, 书内时间: '夏月初三', 一句话事件: '闻古钟', 在场: '林晚' } }]
+    p.secretWrites = [{ id: '信息差-002-钟声', frontMatter: { 读者已知: false, 登记章: 3, 关键词: ['古钟'] }, content: '## 内容\n钟声有古怪。\n' }]
+    const r = await stageChapter(ctx, { chapterNum: 3, payload: p })
+    assert.equal(r.ok, true, r.error)
+
+    const facts = await stagedFacts(ctx.repoPath)
+    assert.equal(facts.exists, true)
+    assert.equal(facts.chapters.length, 1)
+    const t = facts.threads.get('伏笔-009')
+    assert.ok(t, '声明+定稿包都指向的新条目必须在')
+    assert.equal(t.新开, true)
+    assert.equal(t.状态, '进行')
+    assert.ok(facts.newEntities.has('古钟长老'))
+    assert.ok(facts.newAliases.has('钟叟') && facts.newAliases.has('老钟'))
+    assert.deepEqual(facts.characterUpdates.get('林晚'), { 最后变更章: 3 })
+    assert.equal(facts.timelineRows.length, 1)
+    assert.equal(facts.secretWrites.length, 1)
+    assert.equal(facts.总字数, 100)
+  } finally {
+    await cleanup()
+  }
+})