Kaynağa Gözat

fix(v7): 状态机判定对齐 spec 0.9——收卷声明制/体检距上次/序0清单/序3细纲

- 序4 收卷声明制:卷末由 front matter 收卷: 是 声明(chapters 表加 is_volume_end,
  重建器填充),复盘完成以卷摘要存在为准防重复触发;卷规模退出状态机判定
- 序5 体检改「距上次体检 ≥ 体检周期」,记录存缓存 meta 表;新增最小 health-check
  命令(悬了太久/条目活跃率/连续弱钩落工作区报告,文体项如实标注随 M5.5)——
  没有执行点新语义会从第 50 章起卡死主循环
- 序0 源文件清单钉死:补扫 book.yaml/文风铁律/名册/时间线,解析失败进修复确认,
  不再静默降级默认值
- 序3 细纲.md/本章写作材料.md 计入未完成流程,已确认细纲不再被序6 覆盖
- 缓存 schema 版本检查(不匹配自动重建)+ meta 运行标记跨重建保留
  (否则定稿后刷新会抹掉体检记录);CacheManager 增 run()

边界回顾 #5/#6/#7;任务 07-02-v7-boundary-code-fixes 阶段 B;280 测试绿
lingfengQAQ 15 saat önce
ebeveyn
işleme
b0ddc9c803

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

@@ -52,6 +52,7 @@ if (!command || command === '--help') {
   console.log('')
   console.log('状态机 / 例外流程(M3):')
   console.log('  next                                    继续:状态机判定下一步(git 健康检查先行)')
+  console.log('  health-check                            体检:悬了太久/条目活跃率/连续弱钩,报告落工作区(文体项随 M5.5)')
   console.log('  impact <关键词>                          影响分析:哪些章建立在这个事实上(已发布/未发布)')
   console.log('  goto-chapter <章号> [--confirm]          回到第N章(先备份再回滚,作者不碰 git)')
   process.exit(0)

+ 45 - 2
v7/src/cache/index.js

@@ -1,7 +1,7 @@
 import { DatabaseSync } from 'node:sqlite'
 import { promises as fs } from 'node:fs'
 import path from 'node:path'
-import { SCHEMA_SQL } from './schema.js'
+import { SCHEMA_SQL, SCHEMA_VERSION } from './schema.js'
 import { rebuildCache } from './rebuilder.js'
 
 let rebuildCounter = 0
@@ -37,7 +37,7 @@ export class CacheManager {
         const tables = this.db
           .prepare("SELECT name FROM sqlite_master WHERE type='table'")
           .all()
-        if (tables.length === 0) {
+        if (tables.length === 0 || !this._schemaVersionMatches()) {
           this.db.close()
           this.db = null
           needsRebuild = true
@@ -80,6 +80,23 @@ export class CacheManager {
       hadExisting = false
     }
 
+    // 保留 meta 运行标记(如上次体检章号)跨重建:读不到就从零开始(体检重测无害)
+    let preservedMeta = []
+    if (hadExisting) {
+      try {
+        const src = this.db || new DatabaseSync(this.dbPath)
+        try {
+          preservedMeta = src
+            .prepare("SELECT key, value FROM meta WHERE key != 'schema_version'")
+            .all()
+        } finally {
+          if (!this.db) src.close()
+        }
+      } catch {
+        preservedMeta = []
+      }
+    }
+
     // 关闭现有连接
     if (this.db) {
       this.db.close()
@@ -94,6 +111,9 @@ export class CacheManager {
     try {
       tmpDb = new DatabaseSync(tmpPath)
       tmpDb.exec(SCHEMA_SQL)
+      const metaStmt = tmpDb.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)')
+      metaStmt.run('schema_version', String(SCHEMA_VERSION))
+      for (const row of preservedMeta) metaStmt.run(row.key, row.value)
       const result = await rebuildCache(repoPath, tmpDb)
       tmpDb.close()
       tmpDb = null
@@ -135,6 +155,17 @@ export class CacheManager {
     }
   }
 
+  _schemaVersionMatches() {
+    try {
+      const row = this.db
+        .prepare("SELECT value FROM meta WHERE key = 'schema_version'")
+        .get()
+      return row?.value === String(SCHEMA_VERSION)
+    } catch {
+      return false // 无 meta 表 = 旧 schema
+    }
+  }
+
   /**
    * 执行查询。
    * @param {string} sql
@@ -150,6 +181,18 @@ export class CacheManager {
     return stmt.all(...params)
   }
 
+  /**
+   * 执行写语句(INSERT/UPDATE/DELETE)。
+   * @param {string} sql
+   * @param {any[]} params
+   */
+  async run(sql, params = []) {
+    if (!this.db) {
+      throw new Error('数据库未初始化')
+    }
+    return this.db.prepare(sql).run(...params)
+  }
+
   /**
    * 关闭数据库连接。
    * @returns {Promise<void>}

+ 3 - 2
v7/src/cache/rebuilder.js

@@ -72,8 +72,8 @@ async function scanChapters(repoPath, db) {
   try {
     const files = await fs.readdir(chapterDir)
     const insertStmt = db.prepare(`
-      INSERT INTO chapters (chapter_num, title, volume_num, perspective, story_time, word_count, chapter_position, hook_type, mood_position, file_path, is_key_chapter)
-      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+      INSERT INTO chapters (chapter_num, title, volume_num, perspective, story_time, word_count, chapter_position, hook_type, mood_position, is_volume_end, file_path, is_key_chapter)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     `)
 
     for (const file of files) {
@@ -97,6 +97,7 @@ async function scanChapters(repoPath, db) {
           fm.章定位 || '推进',
           fm.钩子 || null,
           fm.情绪定位 || null,
+          fm.收卷 === '是' || fm.收卷 === true ? 1 : 0,
           filePath,
           fm.是否关键章 ? 1 : 0
         )

+ 10 - 1
v7/src/cache/schema.js

@@ -1,6 +1,14 @@
-// 五表 DDL(O4 §1)
+// 六表 + meta DDL(O4 §1;spec 0.9 §11)
+// SCHEMA_VERSION 变更即触发全量重建(缓存是派生物,无迁移)——加列/加表时 +1。
+export const SCHEMA_VERSION = 2
 
 export const SCHEMA_SQL = `
+-- meta 表(schema 版本与运行标记,如上次体检章号;跨重建保留见 CacheManager)
+CREATE TABLE IF NOT EXISTS meta (
+  key TEXT PRIMARY KEY,
+  value TEXT NOT NULL
+);
+
 -- chapters 表
 CREATE TABLE IF NOT EXISTS chapters (
   chapter_num INTEGER PRIMARY KEY,
@@ -12,6 +20,7 @@ CREATE TABLE IF NOT EXISTS chapters (
   chapter_position TEXT NOT NULL,
   hook_type TEXT,
   mood_position TEXT,
+  is_volume_end BOOLEAN DEFAULT 0,
   file_path TEXT NOT NULL,
   is_key_chapter BOOLEAN DEFAULT 0
 );

+ 11 - 0
v7/src/commands/health-check.js

@@ -0,0 +1,11 @@
+import { runHealthCheck } from '../health-check/index.js'
+
+/**
+ * health-check:最小体检(零 token)。报告落 工作区/体检报告.md 并记录体检章号(序 5 判定依据)。
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const r = await runHealthCheck(ctx)
+  if (!r.ok) return { ok: false, error: r.error }
+  return { ok: true, output: `体检完成(第 ${r.maxChapter} 章),报告见 工作区/体检报告.md` }
+}

+ 66 - 0
v7/src/health-check/index.js

@@ -0,0 +1,66 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { assembleBookStatus } from '../prep/book-status.js'
+
+/**
+ * 最小体检(spec 0.9 §10 序 5 的执行点):汇总既有可算项落 工作区/体检报告.md,
+ * 并把本次体检章号记入缓存 meta——序 5 的"距上次体检"判定依赖该记录。
+ * 文体统计项(指纹/高频意象/句式)随 M5.5 落地,报告中如实占位(降级诚实)。
+ * @param {{repoPath: string, cache: object}} ctx
+ * @returns {Promise<{ok: boolean, filePath: string, maxChapter?: number, error: string}>}
+ */
+export async function runHealthCheck(ctx) {
+  try {
+    const { repoPath, cache } = ctx
+    const status = await assembleBookStatus(ctx)
+    if (!status.ok) return { ok: false, filePath: '', error: status.error }
+
+    const rows = await cache.query('SELECT MAX(chapter_num) AS m FROM chapters')
+    const maxChapter = rows[0]?.m || 0
+
+    const activity = await cache.query(
+      'SELECT type, status, COUNT(*) AS c FROM threads GROUP BY type, status ORDER BY type, status'
+    )
+    const typeName = { foreshadow: '伏笔', suspense: '悬念', romance: '感情线' }
+    const 活跃行 = activity.length
+      ? activity.map((r) => `- ${typeName[r.type] || r.type}·${r.status}:${r.c} 条`).join('\n')
+      : '- (无条目)'
+
+    const overdue = status.data.悬了太久
+    const 悬行 = overdue.length
+      ? overdue.map((t) => `- ${t.id}:悬了 ${t.overdue_count} 章`).join('\n')
+      : '- 无'
+
+    const content = [
+      `# 体检报告(第 ${maxChapter} 章)`,
+      '',
+      status.markdown,
+      '',
+      '## 悬了太久(提醒不是错误)',
+      悬行,
+      '',
+      '## 条目活跃率',
+      活跃行,
+      '',
+      '## 连续弱钩',
+      `- ${status.data.连续弱钩} 章`,
+      '',
+      '## 文体指纹 / 高频意象 / 句式体检',
+      '- 随 M5.5 体检里程碑落地,本版不含。',
+      '',
+    ].join('\n')
+
+    const dir = path.join(repoPath, '工作区')
+    await fs.mkdir(dir, { recursive: true })
+    const filePath = path.join(dir, '体检报告.md')
+    await fs.writeFile(filePath, content, 'utf8')
+
+    await cache.run(
+      "INSERT OR REPLACE INTO meta (key, value) VALUES ('last_health_check_chapter', ?)",
+      [String(maxChapter)]
+    )
+    return { ok: true, filePath, maxChapter, error: '' }
+  } catch (err) {
+    return { ok: false, filePath: '', error: `体检失败:${err.message}` }
+  }
+}

+ 69 - 3
v7/src/state-machine/detectors.js

@@ -1,9 +1,15 @@
 import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { parseFrontMatter } from '../storage/parsers/front-matter.js'
+import { parseMarkdownTable } from '../storage/parsers/markdown-table.js'
+import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
 import { createGit } from '../finalize/git.js'
 
-/** 序0:扫描源文件 front matter 解析失败 */
+/**
+ * 序0:扫描源文件解析失败。清单钉死于 spec 0.9 §10(实现不得自行增减):
+ * 六目录 front matter + book.yaml + 文风铁律 front matter + 名册表 + 时间线表。
+ * 任一清单文件解析失败都不得静默降级为默认值。
+ */
 export async function detectParseFailures(repoPath) {
   const subs = [
     '定稿/正文',
@@ -28,6 +34,46 @@ export async function detectParseFailures(repoPath) {
       if (!parsed.ok) failures.push({ file: `${sub}/${f}`, error: parsed.error })
     }
   }
+
+  // book.yaml:存在但解析失败才算(缺失归序 1 建书态)
+  try {
+    await fs.access(path.join(repoPath, 'book.yaml'))
+    const cfg = await new BookConfigReader(repoPath).read()
+    if (!cfg.ok) failures.push({ file: 'book.yaml', error: cfg.error })
+  } catch {
+    // 无 book.yaml
+  }
+
+  // 文风铁律 front matter(文件可选,缺失跳过)
+  try {
+    const fl = await fs.readFile(path.join(repoPath, '文风', '文风铁律.md'), 'utf8')
+    const parsed = parseFrontMatter(fl)
+    if (!parsed.ok) failures.push({ file: '文风/文风铁律.md', error: parsed.error })
+  } catch {
+    // 无文风铁律
+  }
+
+  // 名册表(文件可选,缺失跳过;与重建器的软跳过互补——这里是作者面对的修复确认)
+  try {
+    const roster = await fs.readFile(path.join(repoPath, '定稿', '设定', '名册.md'), 'utf8')
+    const t = parseMarkdownTable(roster)
+    if (!t.ok) failures.push({ file: '定稿/设定/名册.md', error: t.error })
+  } catch {
+    // 无名册
+  }
+
+  // 时间线表(按卷多文件,目录可选)
+  try {
+    const tlDir = path.join(repoPath, '定稿', '设定', '时间线')
+    for (const f of await fs.readdir(tlDir)) {
+      if (!f.endsWith('.md')) continue
+      const t = parseMarkdownTable(await fs.readFile(path.join(tlDir, f), 'utf8'))
+      if (!t.ok) failures.push({ file: `定稿/设定/时间线/${f}`, error: t.error })
+    }
+  } catch {
+    // 无时间线目录
+  }
+
   return failures
 }
 
@@ -57,7 +103,10 @@ export async function hasManualEdits(repoPath) {
   }
 }
 
-/** 序3:工作区有未完成流程(草稿/审稿/待定稿批次) */
+/**
+ * 序3:工作区有未完成流程(细纲/材料/草稿/审稿/待定稿批次,spec 0.9 §10 续跑映射)。
+ * 细纲计入——已确认的细纲是作者决策,不得被序 6 重新起草覆盖。
+ */
 export async function hasUnfinishedWork(repoPath) {
   const ws = path.join(repoPath, '工作区')
   let files
@@ -66,7 +115,13 @@ export async function hasUnfinishedWork(repoPath) {
   } catch {
     return false
   }
-  if (files.some((f) => f.startsWith('草稿') || f === '审稿.md')) return true
+  if (
+    files.some(
+      (f) =>
+        f.startsWith('草稿') || f === '审稿.md' || f === '细纲.md' || f === '本章写作材料.md'
+    )
+  )
+    return true
   try {
     const batch = await fs.readdir(path.join(ws, '待定稿'))
     if (batch.length) return true
@@ -75,3 +130,14 @@ export async function hasUnfinishedWork(repoPath) {
   }
   return false
 }
+
+/** 序4 辅助:该卷的卷摘要已存在 = 卷复盘已完成(防复盘后重复触发) */
+export async function volumeReviewDone(repoPath, volumeNum) {
+  const nn = String(volumeNum).padStart(2, '0')
+  try {
+    await fs.access(path.join(repoPath, '定稿', '摘要', '卷摘要', `第${nn}卷.md`))
+    return true
+  } catch {
+    return false
+  }
+}

+ 1 - 1
v7/src/state-machine/dto.js

@@ -39,7 +39,7 @@ export async function buildDto(ctx, 序, base = {}) {
         state: 'draft-outline',
         nextChapter: base.nextChapter,
         全书近况: status.ok ? status.markdown : '',
-        期望产物: '工作区/细纲.md(含本章定位声明 + 本章要写到的事 + 备选,由 M3 落盘)',
+        期望产物: '工作区/细纲.md(含本章定位声明 + 本章要写到的事 + 备选,由 M3 落盘);卷近尾声时提案可含收卷提议(依据卷纲进度与卷规模参考值,作者确认后定稿写入 收卷: 是)',
       }
     }
     default:

+ 22 - 10
v7/src/state-machine/index.js

@@ -35,22 +35,25 @@ export async function determineNextState(ctx) {
   }
 
   // 序4/5/6 需章号信息
-  const maxRow = await cache.query('SELECT MAX(chapter_num) AS m FROM chapters')
-  const maxChapter = maxRow[0]?.m || 0
+  const lastRows = await cache.query(
+    'SELECT chapter_num, volume_num, is_volume_end FROM chapters ORDER BY chapter_num DESC LIMIT 1'
+  )
+  const last = lastRows[0] || null
+  const maxChapter = last?.chapter_num || 0
   const config = await new BookConfigReader(repoPath).read()
-  const 卷规模 = (config.ok && config.data.卷规模) || 40
   const 体检周期 = (config.ok && config.data.体检周期) || 50
 
-  // 序4 卷复盘(卷末章;对谈=AI)
-  if (maxChapter > 0 && maxChapter % 卷规模 === 0) {
-    return mk(4, 'volume-review', true, `第 ${maxChapter} 章是卷末,进入卷复盘。`, gitHealth, await buildDto(ctx, 4, {
-      卷: Math.floor(maxChapter / 卷规模),
+  // 序4 卷复盘(收卷声明制,spec 0.9 §10:最新定稿章声明了收卷;复盘完成以卷摘要存在为准,防重复触发。对谈=AI)
+  if (last && last.is_volume_end && !(await d.volumeReviewDone(repoPath, last.volume_num))) {
+    return mk(4, 'volume-review', true, `第 ${last.chapter_num} 章已收卷,进入第 ${last.volume_num} 卷复盘。`, gitHealth, await buildDto(ctx, 4, {
+      卷: last.volume_num,
     }))
   }
 
-  // 序5 体检(脚本项;指纹推 M3+)
-  if (maxChapter > 0 && maxChapter % 体检周期 === 0) {
-    return mk(5, 'health-check', false, `已到体检周期(第 ${maxChapter} 章),进入体检。`, gitHealth, {})
+  // 序5 体检(距上次体检 ≥ 体检周期;记录存缓存 meta,丢失重测无害。统计项随 M5.5,最小体检=health-check 命令)
+  const lastCheck = await readLastHealthCheck(cache)
+  if (maxChapter > 0 && maxChapter - lastCheck >= 体检周期) {
+    return mk(5, 'health-check', false, `距上次体检已 ${maxChapter - lastCheck} 章(周期 ${体检周期}),进入体检。`, gitHealth, {})
   }
 
   // 序6 起草新章细纲(近况=脚本,拟提案=AI)
@@ -62,3 +65,12 @@ export async function determineNextState(ctx) {
 function mk(序, state, needsAI, message, gitHealth, dto) {
   return { ok: true, 序, state, needsAI, message, gitHealth, dto }
 }
+
+async function readLastHealthCheck(cache) {
+  try {
+    const rows = await cache.query("SELECT value FROM meta WHERE key = 'last_health_check_chapter'")
+    return parseInt(rows[0]?.value || '0', 10) || 0
+  } catch {
+    return 0
+  }
+}

+ 33 - 0
v7/test/cache/CacheManager.test.js

@@ -75,3 +75,36 @@ test('删除缓存后全量重建,查询结果不变(AC1)', async () => {
 
   await fs.unlink(tmpDb)
 })
+
+test('meta 运行标记跨全量重建保留(体检记录不因定稿后刷新丢失)', async () => {
+  const tmpDb = path.join(os.tmpdir(), `test-meta-keep-${Date.now()}.db`)
+  const cache = new CacheManager(tmpDb)
+  await cache.ensureReady(fixtureRoot)
+
+  await cache.run("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_health_check_chapter', '7')")
+  const r = await cache.rebuildFromSource(fixtureRoot)
+  assert.equal(r.ok, true)
+
+  const rows = await cache.query("SELECT value FROM meta WHERE key = 'last_health_check_chapter'")
+  assert.equal(rows[0]?.value, '7')
+
+  await cache.close()
+  await fs.unlink(tmpDb)
+})
+
+test('schema 版本不匹配的旧缓存 → ensureReady 自动重建', async () => {
+  const tmpDb = path.join(os.tmpdir(), `test-schema-ver-${Date.now()}.db`)
+  const cache1 = new CacheManager(tmpDb)
+  await cache1.ensureReady(fixtureRoot)
+  await cache1.run("UPDATE meta SET value = '0' WHERE key = 'schema_version'")
+  await cache1.close()
+
+  const cache2 = new CacheManager(tmpDb)
+  await cache2.ensureReady(fixtureRoot)
+  const v = await cache2.query("SELECT value FROM meta WHERE key = 'schema_version'")
+  assert.notEqual(v[0]?.value, '0') // 已按当前 schema 重建
+  const chapters = await cache2.query('SELECT COUNT(*) AS c FROM chapters')
+  assert.ok(chapters[0].c > 0)
+  await cache2.close()
+  await fs.unlink(tmpDb)
+})

+ 116 - 6
v7/test/state-machine/router.test.js

@@ -7,6 +7,7 @@ import { execFile } from 'node:child_process'
 import { promisify } from 'node:util'
 import { CacheManager } from '../../src/cache/index.js'
 import { determineNextState } from '../../src/state-machine/index.js'
+import { runHealthCheck } from '../../src/health-check/index.js'
 
 const execFileAsync = promisify(execFile)
 
@@ -42,8 +43,8 @@ async function makeGitBook(files, { commit = true } = {}) {
   }
 }
 
-const ch = (n, vol = 1, pos = '推进') =>
-  `---\n章号: ${n}\n标题: 第${n}章\n卷: ${vol}\n字数: 100\n章定位: ${pos}\n钩子: 危机钩-强\n情绪定位: 铺垫\n---\n正文。`
+const ch = (n, vol = 1, pos = '推进', 收卷 = false) =>
+  `---\n章号: ${n}\n标题: 第${n}章\n卷: ${vol}\n字数: 100\n章定位: ${pos}\n钩子: 危机钩-强\n情绪定位: 铺垫${收卷 ? '\n收卷: 是' : ''}\n---\n正文。`
 
 const healthyBook = (extra = {}) => ({
   'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n体检周期: 50\n',
@@ -113,23 +114,54 @@ test('序3:工作区有未完成草稿 → 断点续跑', async () => {
   }
 })
 
-test('序4:卷末章 → 卷复盘', async () => {
+test('序4:最新定稿章声明收卷 → 卷复盘(声明制)', async () => {
   const { ctx, cleanup } = await makeGitBook({
-    'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 2\n体检周期: 50\n',
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n体检周期: 50\n',
     '大纲/总纲.md': '# 总纲',
     '定稿/正文/0001-第1章.md': ch(1),
-    '定稿/正文/0002-第2章.md': ch(2),
+    '定稿/正文/0002-第2章.md': ch(2, 1, '推进', true),
   })
   try {
     const r = await determineNextState(ctx)
     assert.equal(r.序, 4, `实际:${JSON.stringify(r)}`)
     assert.equal(r.state, 'volume-review')
+    assert.equal(r.dto.卷, 1)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序4 反例:章号为卷规模整数倍但未声明收卷 → 不触发卷复盘(旧整除行为消失)', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 2\n体检周期: 50\n',
+    '大纲/总纲.md': '# 总纲',
+    '定稿/正文/0001-第1章.md': ch(1),
+    '定稿/正文/0002-第2章.md': ch(2),
+  })
+  try {
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 6, `实际:${JSON.stringify(r)}`)
   } finally {
     await cleanup()
   }
 })
 
-test('序5:到体检周期 → 体检', async () => {
+test('序4:收卷但该卷卷摘要已存在(复盘已做)→ 不再触发', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n体检周期: 50\n',
+    '大纲/总纲.md': '# 总纲',
+    '定稿/正文/0001-第1章.md': ch(1, 1, '推进', true),
+    '定稿/摘要/卷摘要/第01卷.md': '# 第一卷摘要\n收束。',
+  })
+  try {
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 6, `实际:${JSON.stringify(r)}`)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序5:距上次体检满周期 → 体检', async () => {
   const { ctx, cleanup } = await makeGitBook({
     'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 3\n体检周期: 2\n',
     '大纲/总纲.md': '# 总纲',
@@ -145,6 +177,84 @@ test('序5:到体检周期 → 体检', async () => {
   }
 })
 
+test('序5:体检执行后记录章号,未到下个周期不再触发(体检不卡主循环)', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n体检周期: 2\n',
+    '大纲/总纲.md': '# 总纲',
+    '定稿/正文/0001-第1章.md': ch(1),
+    '定稿/正文/0002-第2章.md': ch(2),
+  })
+  try {
+    const first = await determineNextState(ctx)
+    assert.equal(first.序, 5)
+    const hc = await runHealthCheck(ctx)
+    assert.equal(hc.ok, true, hc.error)
+    const second = await determineNextState(ctx)
+    assert.equal(second.序, 6, `实际:${JSON.stringify(second)}`)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序0:book.yaml 解析失败 → 修复确认(不得静默用默认值)', async () => {
+  const { ctx, cleanup } = await makeGitBook(
+    healthyBook({ 'book.yaml': '书名: [未闭合\n卷规模: : :\n' })
+  )
+  try {
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 0, `实际:${JSON.stringify(r)}`)
+    assert.ok(r.dto.failures.some((f) => f.file === 'book.yaml'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序0:文风铁律 front matter 解析失败 → 修复确认', async () => {
+  const { ctx, cleanup } = await makeGitBook(
+    healthyBook({ '文风/文风铁律.md': '---\n禁词: [未闭合\n---\n## 铁律\nx' })
+  )
+  try {
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 0)
+    assert.ok(r.dto.failures.some((f) => f.file === '文风/文风铁律.md'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序0:名册/时间线表解析失败 → 修复确认', async () => {
+  const { ctx, cleanup } = await makeGitBook(
+    healthyBook({
+      '定稿/设定/名册.md': '## 名册\n没有表格',
+      '定稿/设定/时间线/第01卷.md': '不是表格的内容',
+    })
+  )
+  try {
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 0)
+    const files = r.dto.failures.map((f) => f.file)
+    assert.ok(files.includes('定稿/设定/名册.md'), `实际:${files}`)
+    assert.ok(files.includes('定稿/设定/时间线/第01卷.md'), `实际:${files}`)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序3:工作区仅存细纲 → 断点续跑,细纲不被改写(AC5)', async () => {
+  const { ctx, root, cleanup } = await makeGitBook(healthyBook())
+  try {
+    const 细纲内容 = '# 第 2 章细纲\n## 本章要写到的事\n- [ ] 已确认的事'
+    await fs.mkdir(path.join(root, '工作区'), { recursive: true })
+    await fs.writeFile(path.join(root, '工作区/细纲.md'), 细纲内容, 'utf8')
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 3, `实际:${JSON.stringify(r)}`)
+    assert.equal(r.state, 'resume')
+    assert.equal(await fs.readFile(path.join(root, '工作区/细纲.md'), 'utf8'), 细纲内容)
+  } finally {
+    await cleanup()
+  }
+})
+
 test('命中即停:手改(序2) + 工作区草稿(序3) 同时存在 → 先报序2', async () => {
   const { ctx, root, cleanup } = await makeGitBook(healthyBook())
   try {