Parcourir la source

feat(v7): M5 F1 宿主 CLI 缝——写章八阶段全程 CLI 可驱动

- 8 命令:next --json/review-input/save-review/persist-{outline,book,volume-review,repair}/finalize
- JSON 一律 --file/--payload 文件路径,不走 stdin(Windows 中文管道雷区);人话报错
- review 层抽 saveReviews(校验+合并+落盘)供 runReviews 与 CLI 共用
- persist-book 工作目录模式:建书目录+登记置当前;书仓库直启落 cwd 兼容
- persistCreateBook 补指路 AGENTS.md + init commit + git 身份局部兜底(否则建书完 next 误触序2)
- finalize CLI 归一 workspaceFiles 前缀,防宿主写 工作区/x 被静默漏清
- 出口 D2:主循环全程子进程 spawn bin 跑通(建书→细纲→备料→机检→两审→定稿→next 报第2章,中文路径);324 绿
lingfengQAQ il y a 1 jour
Parent
commit
58ccce0

+ 35 - 0
v7/src/commands/finalize.js

@@ -0,0 +1,35 @@
+import { finalizeChapter } from '../finalize/index.js'
+import { readJsonInput } from '../util/json-input.js'
+
+/**
+ * finalize <章号> --payload=<定稿包json路径>:原子 commit(正文入定稿、条目/设定/时间线更新、
+ * 章摘要入档、工作区清理)+ 缓存刷新。payload 字段见 finalizeChapter。
+ * workspaceFiles 是工作区内文件名;宿主写成「工作区/xx」也接受(此处归一,防静默漏清)。
+ * 契约:纯返回 {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 (payload.chapterNum !== undefined && payload.chapterNum !== chapterNum) {
+    return { ok: false, error: `章号不一致:命令行是 ${chapterNum},payload 里是 ${payload.chapterNum}` }
+  }
+  if (Array.isArray(payload.workspaceFiles)) {
+    payload.workspaceFiles = payload.workspaceFiles.map((f) =>
+      String(f).replace(/^工作区[\\/]/, '')
+    )
+  }
+
+  const r = await finalizeChapter(ctx, { ...payload, chapterNum })
+  if (!r.ok) return { ok: false, error: r.error }
+
+  const lines = [`第 ${chapterNum} 章已定稿(commit ${String(r.commitHash || '').slice(0, 8)})。`]
+  if (r.cacheRefresh && r.cacheRefresh.ok === false) {
+    lines.push(`缓存刷新失败(下次命令会自动重建):${(r.cacheRefresh.errors || []).join(';')}`)
+  }
+  lines.push('继续运行 next 判定下一步。')
+  return { ok: true, output: lines.join('\n') }
+}

+ 55 - 0
v7/src/commands/persist-book.js

@@ -0,0 +1,55 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { persistCreateBook } from '../state-machine/persist.js'
+import { registerBook } from '../session/index.js'
+import { readJsonInput } from '../util/json-input.js'
+
+/**
+ * persist-book --file=<json> [--dir=<目录名>]:序1 建书产物回流。
+ * 工作目录模式:建 workdir/<目录>/ 落盘 + books.jsonl 登记置当前(目录名取 --dir,缺省书名)。
+ * 书仓库直启(cwd 含 book.yaml,开发/测试):落 cwd,不登记(无工作目录层)。
+ * 契约:纯返回 {ok, output?, error?}。
+ */
+export const scope = 'workdir-or-book'
+
+export async function run(args, options, ctx) {
+  const spec = await readJsonInput(ctx, options.file, 'file')
+  if (!spec.ok) return { ok: false, error: spec.error }
+  const { book, 总纲, 卷纲 } = spec.data
+  if (!book || typeof book !== 'object' || !book.书名) {
+    return { ok: false, error: 'JSON 需含对象字段「book」(book.yaml 内容,至少有 书名)' }
+  }
+  if (typeof 总纲 !== 'string' || !总纲.trim()) return { ok: false, error: 'JSON 需含非空字符串字段「总纲」' }
+  if (typeof 卷纲 !== 'string' || !卷纲.trim()) return { ok: false, error: 'JSON 需含非空字符串字段「卷纲」' }
+
+  // 书仓库直启:落当前书仓库
+  if (ctx.repoPath) {
+    const r = await persistCreateBook(ctx, { book, 总纲, 卷纲 })
+    return r.ok
+      ? { ok: true, output: `建书完成:${r.written.join('、')}` }
+      : { ok: false, error: r.error }
+  }
+
+  // 工作目录模式:建书目录 + 登记
+  const dirName = options.dir && options.dir !== true ? options.dir : String(book.书名)
+  if (/[\\/]/.test(dirName) || dirName.includes('..') || dirName.startsWith('.')) {
+    return { ok: false, error: `书目录名不合法:${dirName}(须是工作目录下的一层普通目录名)` }
+  }
+  const repoPath = path.join(ctx.workdir, dirName)
+  try {
+    await fs.access(path.join(repoPath, 'book.yaml'))
+    return { ok: false, error: `已有同名书目录 ${dirName}/(含 book.yaml),不覆盖。换个书名或用 --dir 指定其他目录。` }
+  } catch {
+    // 目录不存在或还不是书——可建
+  }
+  const r = await persistCreateBook({ repoPath }, { book, 总纲, 卷纲 })
+  if (!r.ok) return { ok: false, error: r.error }
+  const reg = await registerBook(ctx.workdir, { 书名: String(book.书名), 目录: dirName })
+  if (!reg.ok) {
+    return { ok: false, error: `书已建成(${dirName}/)但登记失败:${reg.error}。运行 list-books 触发书单重建。` }
+  }
+  return {
+    ok: true,
+    output: `建书完成:《${book.书名}》(目录 ${dirName}/),已登记为当前书。继续运行 next 判定下一步。`,
+  }
+}

+ 17 - 0
v7/src/commands/persist-outline.js

@@ -0,0 +1,17 @@
+import { persistDraftOutline } from '../state-machine/persist.js'
+import { readJsonInput } from '../util/json-input.js'
+
+/**
+ * persist-outline --file=<json>:序6 细纲产物回流({细纲} → 工作区/细纲.md)。
+ * 契约:纯返回 {ok, output?, error?}。
+ */
+export async function run(args, options, ctx) {
+  const spec = await readJsonInput(ctx, options.file, 'file')
+  if (!spec.ok) return { ok: false, error: spec.error }
+  const { 细纲 } = spec.data
+  if (typeof 细纲 !== 'string' || !细纲.trim()) {
+    return { ok: false, error: 'JSON 需含非空字符串字段「细纲」' }
+  }
+  const r = await persistDraftOutline(ctx, { 细纲 })
+  return r.ok ? { ok: true, output: `已写出 ${r.written.join('、')}` } : { ok: false, error: r.error }
+}

+ 26 - 0
v7/src/commands/persist-repair.js

@@ -0,0 +1,26 @@
+import { persistRepair } from '../state-machine/persist.js'
+import { detectParseFailures } from '../state-machine/detectors.js'
+import { readJsonInput } from '../util/json-input.js'
+
+/**
+ * persist-repair --file=<json>:序0 修复方案回流({repairs:[{file,content}]})。
+ * 安全网在用例层:只写当前检测失败清单内的文件,内容必须能解析。
+ * 契约:纯返回 {ok, output?, error?}。
+ */
+export async function run(args, options, ctx) {
+  const spec = await readJsonInput(ctx, options.file, 'file')
+  if (!spec.ok) return { ok: false, error: spec.error }
+  const { repairs } = spec.data
+  if (!Array.isArray(repairs) || !repairs.length) {
+    return { ok: false, error: 'JSON 需含非空数组字段「repairs」(每项 {file, content})' }
+  }
+  for (const r of repairs) {
+    if (!r || typeof r.file !== 'string' || typeof r.content !== 'string') {
+      return { ok: false, error: 'repairs 每项需含字符串字段 file 与 content' }
+    }
+  }
+  const failures = await detectParseFailures(ctx.repoPath)
+  const allowedFiles = failures.map((f) => f.file)
+  const r = await persistRepair(ctx, { repairs }, { allowedFiles })
+  return r.ok ? { ok: true, output: `已修复 ${r.written.join('、')}` } : { ok: false, error: r.error }
+}

+ 22 - 0
v7/src/commands/persist-volume-review.js

@@ -0,0 +1,22 @@
+import { persistVolumeReview } from '../state-machine/persist.js'
+import { readJsonInput } from '../util/json-input.js'
+
+/**
+ * persist-volume-review --file=<json>:序4 卷复盘产物回流
+ * ({卷号, 卷摘要, 下卷卷纲?, 伏笔条目?} → 卷摘要/下卷卷纲/伏笔条目落盘)。
+ * 契约:纯返回 {ok, output?, error?}。
+ */
+export async function run(args, options, ctx) {
+  const spec = await readJsonInput(ctx, options.file, 'file')
+  if (!spec.ok) return { ok: false, error: spec.error }
+  const { 卷号, 卷摘要, 下卷卷纲, 伏笔条目 } = spec.data
+  if (!Number.isInteger(卷号) || 卷号 < 1) return { ok: false, error: 'JSON 需含正整数字段「卷号」' }
+  if (typeof 卷摘要 !== 'string' || !卷摘要.trim()) {
+    return { ok: false, error: 'JSON 需含非空字符串字段「卷摘要」' }
+  }
+  if (伏笔条目 !== undefined && !Array.isArray(伏笔条目)) {
+    return { ok: false, error: '「伏笔条目」需是数组(每项 {id, frontMatter, body})' }
+  }
+  const r = await persistVolumeReview(ctx, { 卷号, 卷摘要, 下卷卷纲, 伏笔条目: 伏笔条目 || [] })
+  return r.ok ? { ok: true, output: `已写出 ${r.written.join('、')}` } : { ok: false, error: r.error }
+}

+ 26 - 0
v7/src/commands/review-input.js

@@ -0,0 +1,26 @@
+import path from 'node:path'
+import { assembleReviewInput } from '../review/index.js'
+import { writeAtomicBatch } from '../storage/atomic.js'
+
+/**
+ * review-input <章号> [--draft=<repo相对路径>]:组装两审共用的 ReviewInput 并落
+ * 工作区/审稿输入.json(含草稿全文,大 JSON 走文件,宿主用读文件工具吃)。
+ * 缺省草稿 工作区/草稿-A.md(与 mechanical-check 一致)。
+ * 契约:纯返回 {ok, output?, error?}。
+ */
+export async function run(args, options, ctx) {
+  const chapterNum = parseInt(args[0], 10)
+  if (isNaN(chapterNum)) return { ok: false, error: '章号必须是数字' }
+  const draftPath =
+    options.draft && options.draft !== true ? options.draft : path.join('工作区', '草稿-A.md')
+
+  const r = await assembleReviewInput(ctx, { chapterNum, draftPath })
+  if (!r.ok) return { ok: false, error: r.error }
+
+  const rel = path.join('工作区', '审稿输入.json')
+  await writeAtomicBatch(ctx.repoPath, [{ path: rel, content: JSON.stringify(r.input, null, 2) }])
+  return {
+    ok: true,
+    output: `已写出 ${rel}(两审共用同一份输入;含 拟条目变动 ${r.input.拟条目变动.length} 项、相关角色 ${r.input.相关角色.length} 个)`,
+  }
+}

+ 49 - 0
v7/src/commands/save-review.js

@@ -0,0 +1,49 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { saveReviews } from '../review/index.js'
+import { readJsonInput } from '../util/json-input.js'
+
+/**
+ * save-review <章号> --file=<两审json> [--draft=<repo相对路径>]:两审产物入库。
+ * JSON:{factCheck|事实审查, editorial|编辑审, mode?, 待确认新专名?, 章摘要?}。
+ * schema 校验 → 合并 → 落 工作区/审稿.md + 评审报告/(原始输出与归一化分存)。
+ * 契约:纯返回 {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.file, 'file')
+  if (!spec.ok) return { ok: false, error: spec.error }
+  const d = spec.data
+  const rawFact = d.factCheck ?? d.事实审查
+  const rawEdit = d.editorial ?? d.编辑审
+  if (!rawFact || !rawEdit) {
+    return { ok: false, error: 'JSON 需含「factCheck/事实审查」与「editorial/编辑审」两份审稿报告' }
+  }
+  const mode = d.mode === 'degraded' ? 'degraded' : 'complete'
+
+  const draftRel =
+    options.draft && options.draft !== true ? options.draft : path.join('工作区', '草稿-A.md')
+  let draft
+  try {
+    draft = await fs.readFile(path.join(ctx.repoPath, draftRel), 'utf8')
+  } catch (err) {
+    return { ok: false, error: `读不到草稿 ${draftRel}:${err.message}(审稿单要附草稿原文)` }
+  }
+
+  const r = await saveReviews(ctx, {
+    chapterNum,
+    rawFact,
+    rawEdit,
+    mode,
+    待确认新专名: Array.isArray(d.待确认新专名) ? d.待确认新专名 : [],
+    章摘要: typeof d.章摘要 === 'string' ? d.章摘要 : '',
+    draft,
+  })
+  if (!r.ok) return { ok: false, error: `两审报告未过 schema 校验:\n- ${r.errors.join('\n- ')}` }
+  return {
+    ok: true,
+    output: `审稿单已落 ${path.join('工作区', '审稿.md')}:共 ${r.merged.issues_count} 个问题,${r.merged.blocking_count} 个阻断${r.merged.has_blocking ? '(有阻断,需处理后再定稿)' : ''}`,
+  }
+}

+ 11 - 0
v7/src/finalize/git.js

@@ -29,6 +29,17 @@ export function createGit(repoPath) {
     async setQuotepathFalse() {
       await run(['config', 'core.quotepath', 'false'])
     },
+    /** 提交身份兜底:作者不是程序员,机器可能没配 git 身份;仅设本仓库局部,不碰全局 */
+    async ensureIdentity() {
+      try {
+        const { stdout } = await run(['config', 'user.name'])
+        if (stdout.trim()) return
+      } catch {
+        // 未配置 → 落到下面补
+      }
+      await run(['config', 'user.name', 'webnovel-writer'])
+      await run(['config', 'user.email', 'webnovel-writer@local'])
+    },
     /** 撤销 paths 下已跟踪文件的未提交修改(不碰其他路径如 工作区/) */
     async restore(paths) {
       try {

+ 28 - 11
v7/src/review/index.js

@@ -211,6 +211,29 @@ export async function persistReviewReport(ctx, { chapterNum, merged, draft, 待
   return { ok: true, 审稿路径, error: '' }
 }
 
+/**
+ * 两审产物入库:schema 校验 → 合并 → 落盘。runReviews 与 save-review CLI 共用,不双写。
+ * @param {{repoPath}} ctx
+ * @param {{chapterNum, rawFact, rawEdit, mode?, 待确认新专名?, 章摘要?, draft}} args
+ */
+export async function saveReviews(ctx, { chapterNum, rawFact, rawEdit, mode = 'complete', 待确认新专名 = [], 章摘要 = '', draft }) {
+  const vFact = validateReviewReport(rawFact, { reviewType: 'factCheck' })
+  const vEdit = validateReviewReport(rawEdit, { reviewType: 'editorial' })
+  const errors = [...vFact.errors, ...vEdit.errors]
+  if (errors.length) return { ok: false, errors }
+
+  const merged = mergeReviews({ factCheck: vFact.report, editorial: vEdit.report }, { mode, chapterNum })
+  const saved = await persistReviewReport(ctx, {
+    chapterNum,
+    merged,
+    draft,
+    待确认新专名,
+    章摘要,
+    raw: { factCheck: rawFact, editorial: rawEdit },
+  })
+  return { ok: true, merged, 审稿路径: saved.审稿路径, errors: [] }
+}
+
 /**
  * 两审编排:组装输入 → DI 注入两审 → 校验 → 合并 → 落盘。零真 AI(reviewers 由宿主壳/测试注入)。
  * @param {{repoPath, cache}} ctx
@@ -234,19 +257,13 @@ export async function runReviews(ctx, { chapterNum, draftPath, mode = 'complete'
     rawEdit = await reviewers.editorial(inp.input)
   }
 
-  const vFact = validateReviewReport(rawFact, { reviewType: 'factCheck' })
-  const vEdit = validateReviewReport(rawEdit, { reviewType: 'editorial' })
-  const errors = [...vFact.errors, ...vEdit.errors]
-  if (errors.length) return { ok: false, errors }
-
-  const merged = mergeReviews({ factCheck: vFact.report, editorial: vEdit.report }, { mode, chapterNum })
-  const saved = await persistReviewReport(ctx, {
+  return saveReviews(ctx, {
     chapterNum,
-    merged,
-    draft: inp.input.草稿全文,
+    rawFact,
+    rawEdit,
+    mode,
     待确认新专名,
     章摘要,
-    raw: { factCheck: rawFact, editorial: rawEdit },
+    draft: inp.input.草稿全文,
   })
-  return { ok: true, merged, 审稿路径: saved.审稿路径, errors: [] }
 }

+ 21 - 1
v7/src/state-machine/persist.js

@@ -38,7 +38,22 @@ export async function persistDraftOutline(ctx, { 细纲 }) {
   }
 }
 
-/** 序1 建书 → book.yaml + 大纲/总纲.md + 大纲/卷纲/第01卷.md + .gitignore + git init + core.quotepath */
+/** 书仓库指路 AGENTS.md(spec §2.1 建书时自动生成)。内容内嵌:运行时 vendored 副本不带 templates/ */
+function bookAgentsMd(书名) {
+  return [
+    '# webnovel-writer 书仓库',
+    '',
+    `本目录是《${书名}》的书仓库(定稿 / 大纲 / 文风 / 工作区)。AI 工具不要在这里启动——各平台壳装在上一层工作目录。`,
+    '',
+    '- 日常写作:回上一层工作目录启动 agent CLI,对它说「继续」。',
+    '- 单独 clone 了本仓库:先在期望的工作目录运行 `npx webnovel-writer init`,把本仓库放进工作目录,再运行 `webnovel-writer list-books` 重建书单。',
+    '',
+    '事实变更只经定稿流程入 git。',
+    '',
+  ].join('\n')
+}
+
+/** 序1 建书 → book.yaml + 大纲/总纲.md + 大纲/卷纲/第01卷.md + AGENTS.md 指路 + .gitignore + git init + core.quotepath */
 export async function persistCreateBook(ctx, { book, 总纲, 卷纲 }) {
   try {
     const gitignore = await buildGitignore(ctx.repoPath, ['.cache/', '工作区/'])
@@ -46,12 +61,17 @@ export async function persistCreateBook(ctx, { book, 总纲, 卷纲 }) {
       { path: 'book.yaml', content: serializeYAML(book) },
       { path: path.join('大纲', '总纲.md'), content: 总纲 },
       { path: path.join('大纲', '卷纲', '第01卷.md'), content: 卷纲 },
+      { path: 'AGENTS.md', content: bookAgentsMd(book?.书名 || '未命名') },
       { path: '.gitignore', content: gitignore },
     ])
     // P0-2:书仓库工程化(spec quality §3.3 钉死建书流程负责 git init + core.quotepath)
     const git = createGit(ctx.repoPath)
     await git.init()
     await git.setQuotepathFalse()
+    // 建书产物随手提交(一次性 init 前缀):否则 next 立刻误触序2 手改检测;身份未配则局部兜底
+    await git.ensureIdentity()
+    await git.add(written)
+    await git.commit(`init: 建书《${book?.书名 || '未命名'}》`)
     return { ok: true, written, error: '' }
   } catch (err) {
     return { ok: false, written: [], error: `建书落盘失败:${err.message}` }

+ 30 - 0
v7/src/util/json-input.js

@@ -0,0 +1,30 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+
+/**
+ * F1 通道的 JSON 输入一律走文件(--file/--payload,Windows 中文管道编码雷区,不走 stdin)。
+ * 相对路径相对 ctx.repoPath(书仓库),无书时相对 ctx.workdir。
+ * @returns {Promise<{ok: true, data: object}|{ok: false, error: string}>}
+ */
+export async function readJsonInput(ctx, value, flagName) {
+  if (!value || value === true) {
+    return { ok: false, error: `缺少 --${flagName}=<json文件路径>(JSON 走文件,不走管道)` }
+  }
+  const base = ctx.repoPath || ctx.workdir || process.cwd()
+  const full = path.isAbsolute(value) ? value : path.resolve(base, value)
+  let raw
+  try {
+    raw = await fs.readFile(full, 'utf8')
+  } catch (err) {
+    return { ok: false, error: `读不到 ${value}:${err.message}` }
+  }
+  try {
+    const data = JSON.parse(raw)
+    if (!data || typeof data !== 'object' || Array.isArray(data)) {
+      return { ok: false, error: `${value} 的内容需要是 JSON 对象` }
+    }
+    return { ok: true, data }
+  } catch (err) {
+    return { ok: false, error: `${value} 不是合法 JSON:${err.message}` }
+  }
+}

+ 275 - 0
v7/test/commands/f1-seams.test.js

@@ -0,0 +1,275 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import os from 'node:os'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { makeGitBook, chapter } from '../state-machine/_helper.js'
+import { run as nextRun } from '../../src/commands/next.js'
+import { run as persistOutline } from '../../src/commands/persist-outline.js'
+import { run as persistVolumeReview } from '../../src/commands/persist-volume-review.js'
+import { run as persistRepair } from '../../src/commands/persist-repair.js'
+import { run as persistBook } from '../../src/commands/persist-book.js'
+import { run as reviewInput } from '../../src/commands/review-input.js'
+import { run as saveReview } from '../../src/commands/save-review.js'
+import { run as finalizeCmd } from '../../src/commands/finalize.js'
+import { readBooksRegistry } from '../../src/session/index.js'
+
+const BOOK = 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n体检周期: 50\n'
+
+async function jsonFile(dir, name, data) {
+  const p = path.join(dir, name)
+  await fs.writeFile(p, JSON.stringify(data, null, 2), 'utf8')
+  return p
+}
+
+test('next --json:输出完整状态机 DTO(F1 C1)', async () => {
+  const { ctx, cleanup } = await makeGitBook({ 'book.yaml': BOOK, '定稿/正文/0001-起.md': chapter(1) })
+  try {
+    const r = await nextRun([], { json: true }, ctx)
+    assert.equal(r.ok, true)
+    const dto = JSON.parse(r.output)
+    for (const key of ['ok', 'gitHealth', '序', 'state', 'needsAI', 'message', 'dto']) {
+      assert.ok(key in dto, `缺字段 ${key}`)
+    }
+    assert.equal(typeof dto.序, 'number')
+    // 缺省输出仍是人读
+    const human = await nextRun([], {}, ctx)
+    assert.ok(human.output.includes('【当前状态】'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('persist-outline:--file 落细纲;缺文件/坏 JSON/缺字段人话报错', async () => {
+  const { root, ctx, cleanup } = await makeGitBook({ 'book.yaml': BOOK })
+  try {
+    const p = await jsonFile(os.tmpdir(), `wnw-ol-${process.pid}.json`, { 细纲: '## 本章要写到的事\n突破。' })
+    const r = await persistOutline([], { file: p }, ctx)
+    assert.equal(r.ok, true)
+    const content = await fs.readFile(path.join(root, '工作区', '细纲.md'), 'utf8')
+    assert.ok(content.includes('突破'))
+
+    const miss = await persistOutline([], {}, ctx)
+    assert.equal(miss.ok, false)
+    assert.ok(miss.error.includes('--file'))
+
+    const gone = await persistOutline([], { file: path.join(os.tmpdir(), '不存在.json') }, ctx)
+    assert.equal(gone.ok, false)
+    assert.ok(gone.error.includes('读不到'))
+
+    const badPath = path.join(os.tmpdir(), `wnw-bad-${process.pid}.json`)
+    await fs.writeFile(badPath, '{坏的', 'utf8')
+    const bad = await persistOutline([], { file: badPath }, ctx)
+    assert.equal(bad.ok, false)
+    assert.ok(bad.error.includes('JSON'))
+
+    const empty = await jsonFile(os.tmpdir(), `wnw-empty-${process.pid}.json`, {})
+    const noField = await persistOutline([], { file: empty }, ctx)
+    assert.equal(noField.ok, false)
+    assert.ok(noField.error.includes('细纲'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('persist-volume-review:卷摘要+下卷卷纲落盘;坏卷号报错', async () => {
+  const { root, ctx, cleanup } = await makeGitBook({ 'book.yaml': BOOK })
+  try {
+    const p = await jsonFile(os.tmpdir(), `wnw-vr-${process.pid}.json`, {
+      卷号: 1,
+      卷摘要: '# 第一卷\n入门完毕。',
+      下卷卷纲: '# 第2卷\n出山。',
+    })
+    const r = await persistVolumeReview([], { file: p }, ctx)
+    assert.equal(r.ok, true)
+    await fs.access(path.join(root, '定稿', '摘要', '卷摘要', '第01卷.md'))
+    await fs.access(path.join(root, '大纲', '卷纲', '第02卷.md'))
+
+    const bad = await jsonFile(os.tmpdir(), `wnw-vr-bad-${process.pid}.json`, { 卷号: 0, 卷摘要: 'x' })
+    const rb = await persistVolumeReview([], { file: bad }, ctx)
+    assert.equal(rb.ok, false)
+    assert.ok(rb.error.includes('卷号'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('persist-repair:修检测失败清单内文件;清单外拒绝', async () => {
+  const { root, ctx, cleanup } = await makeGitBook({ 'book.yaml': BOOK })
+  try {
+    // 造一个解析失败的伏笔条目(检测后进入 allowedFiles)
+    const brokenRel = '大纲/伏笔/伏笔-001-试.md'
+    await fs.mkdir(path.join(root, '大纲', '伏笔'), { recursive: true })
+    await fs.writeFile(path.join(root, brokenRel), '---\na: [未闭合\n---\n正文', 'utf8')
+
+    const fixed = '---\nid: 伏笔-001\n短题: 试\n状态: 进行\n---\n正文'
+    const p = await jsonFile(os.tmpdir(), `wnw-rp-${process.pid}.json`, {
+      repairs: [{ file: brokenRel, content: fixed }],
+    })
+    const r = await persistRepair([], { file: p }, ctx)
+    assert.equal(r.ok, true, r.error)
+    const after = await fs.readFile(path.join(root, brokenRel), 'utf8')
+    assert.ok(after.includes('id: 伏笔-001'))
+
+    // 清单外文件(book.yaml 好好的)→ 拒绝
+    const evil = await jsonFile(os.tmpdir(), `wnw-rp-evil-${process.pid}.json`, {
+      repairs: [{ file: 'book.yaml', content: '---\nx: 1\n---\n' }],
+    })
+    const re = await persistRepair([], { file: evil }, ctx)
+    assert.equal(re.ok, false)
+    assert.ok(re.error.includes('拒绝'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('persist-book:工作目录模式建书+指路 AGENTS.md+登记置当前;同名防覆盖', async () => {
+  const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-pb-'))
+  try {
+    await fs.mkdir(path.join(workdir, '.webnovel'), { recursive: true })
+    const p = await jsonFile(os.tmpdir(), `wnw-pb-${process.pid}.json`, {
+      book: { spec_version: '7.0', 书名: '剑起青云', 卷规模: 40 },
+      总纲: '# 总纲\n## 结局\n登顶。',
+      卷纲: '# 第1卷\n入门。',
+    })
+    const ctx = { workdir, repoPath: null }
+    const r = await persistBook([], { file: p }, ctx)
+    assert.equal(r.ok, true, r.error)
+    const repo = path.join(workdir, '剑起青云')
+    await fs.access(path.join(repo, 'book.yaml'))
+    const agents = await fs.readFile(path.join(repo, 'AGENTS.md'), 'utf8')
+    assert.ok(agents.includes('工作目录') && agents.includes('剑起青云'), '指路 AGENTS.md 应指回工作目录')
+    const reg = await readBooksRegistry(workdir)
+    assert.equal(reg.books.length, 1)
+    assert.equal(reg.books[0].当前, true)
+
+    // 同名再建 → 防覆盖
+    const again = await persistBook([], { file: p }, ctx)
+    assert.equal(again.ok, false)
+    assert.ok(again.error.includes('不覆盖'))
+
+    // 目录名不合法
+    const badDir = await persistBook([], { file: p, dir: '../逃逸' }, ctx)
+    assert.equal(badDir.ok, false)
+    assert.ok(badDir.error.includes('不合法'))
+  } finally {
+    await fs.rm(workdir, { recursive: true, force: true })
+  }
+})
+
+test('persist-book:书仓库直启落 cwd,不登记(开发/测试兼容)', async () => {
+  const { root, ctx, cleanup } = await makeGitBook({ 'book.yaml': BOOK })
+  try {
+    const p = await jsonFile(os.tmpdir(), `wnw-pb2-${process.pid}.json`, {
+      book: { spec_version: '7.0', 书名: '测' },
+      总纲: '# 总纲\nx',
+      卷纲: '# 第1卷\ny',
+    })
+    const r = await persistBook([], { file: p }, ctx)
+    assert.equal(r.ok, true, r.error)
+    await fs.access(path.join(root, 'AGENTS.md'))
+  } finally {
+    await cleanup()
+  }
+})
+
+const charCard = `---\n姓名: 林晚\n状态: 在世\n位置: 青云宗\n境界: 练气三层\n---\n## 设定\n玉佩线索。`
+
+test('review-input:落 工作区/审稿输入.json(含草稿全文与章号)', async () => {
+  const { root, ctx, cleanup } = await makeGitBook({
+    'book.yaml': BOOK,
+    '定稿/设定/角色/林晚.md': charCard,
+    '工作区/草稿-A.md': '林晚握紧玉佩。',
+  })
+  try {
+    const r = await reviewInput(['1'], {}, ctx)
+    assert.equal(r.ok, true, r.error)
+    const raw = await fs.readFile(path.join(root, '工作区', '审稿输入.json'), 'utf8')
+    const input = JSON.parse(raw)
+    assert.equal(input.章号, 1)
+    assert.ok(input.草稿全文.includes('玉佩'))
+    assert.ok(input.相关角色.some((c) => c.姓名 === '林晚' || c.正名 === '林晚'))
+
+    const gone = await reviewInput(['1'], { draft: '工作区/没有.md' }, ctx)
+    assert.equal(gone.ok, false)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('save-review:两审 JSON 入库落审稿单;schema 不过人话报错', async () => {
+  const { root, ctx, cleanup } = await makeGitBook({
+    'book.yaml': BOOK,
+    '工作区/草稿-A.md': '林晚握紧玉佩。',
+  })
+  try {
+    const good = await jsonFile(os.tmpdir(), `wnw-sr-${process.pid}.json`, {
+      事实审查: { chapter: 1, issues: [] },
+      编辑审: {
+        chapter: 1,
+        issues: [
+          {
+            severity: 'high',
+            category: 'pacing',
+            location: '第1段',
+            description: '开头太平',
+            evidence: '首段无钩子',
+            fix_hint: '前移冲突',
+            blocking: false,
+          },
+        ],
+      },
+      章摘要: '林晚得玉佩。',
+    })
+    const r = await saveReview(['1'], { file: good }, ctx)
+    assert.equal(r.ok, true, r.error)
+    assert.ok(r.output.includes('1 个问题'))
+    const md = await fs.readFile(path.join(root, '工作区', '审稿.md'), 'utf8')
+    assert.ok(md.includes('开头太平') && md.includes('林晚得玉佩'))
+    await fs.access(path.join(root, '工作区', '评审报告', '事实审查.json'))
+
+    const bad = await jsonFile(os.tmpdir(), `wnw-sr-bad-${process.pid}.json`, {
+      事实审查: { chapter: 1, issues: [{ severity: '不存在的级别' }] },
+      编辑审: { chapter: 1, issues: [] },
+    })
+    const rb = await saveReview(['1'], { file: bad }, ctx)
+    assert.equal(rb.ok, false)
+    assert.ok(rb.error.includes('schema'))
+
+    const noDraft = await saveReview(['1'], { file: good, draft: '工作区/无.md' }, ctx)
+    assert.equal(noDraft.ok, false)
+    assert.ok(noDraft.error.includes('草稿'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('finalize:--payload 定稿入档 + commit;章号不一致防呆', async () => {
+  const { root, ctx, cleanup } = await makeGitBook({
+    'book.yaml': BOOK,
+    '工作区/草稿-A.md': '林晚突破。',
+  })
+  try {
+    const payload = await jsonFile(os.tmpdir(), `wnw-fz-${process.pid}.json`, {
+      frontMatter: { 章号: 1, 标题: '突破', 卷: 1, 字数: 5, 章定位: '推进' },
+      body: '林晚突破。',
+      summary: '林晚突破练气四层。',
+      commitLines: {},
+      workspaceFiles: ['工作区/草稿-A.md'],
+    })
+    const r = await finalizeCmd(['1'], { payload }, ctx)
+    assert.equal(r.ok, true, r.error)
+    assert.ok(r.output.includes('已定稿'))
+    await fs.access(path.join(root, '定稿', '正文', '0001-突破.md'))
+
+    const mismatch = await jsonFile(os.tmpdir(), `wnw-fz-mm-${process.pid}.json`, {
+      chapterNum: 2,
+      frontMatter: { 章号: 2, 标题: 'x' },
+    })
+    const rm = await finalizeCmd(['1'], { payload: mismatch }, ctx)
+    assert.equal(rm.ok, false)
+    assert.ok(rm.error.includes('不一致'))
+  } finally {
+    await cleanup()
+  }
+})

+ 138 - 0
v7/test/integration/cli-main-loop.test.js

@@ -0,0 +1,138 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import os from 'node:os'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+
+const exec = promisify(execFile)
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const BIN = path.join(__dirname, '../../bin/webnovel-writer.js')
+
+/**
+ * M5 出口判据 D2(AC5):写章八阶段每一步走宿主可调的 CLI 通道,全程子进程 spawn bin,
+ * 零进程内调用。建书 → next → 细纲 → 草稿 → 备料 → 机检 → 审稿输入 → 两审入库 → 定稿 → next 不重抄。
+ * 路径含中文(工作目录名/书名),兼做中文路径链路探针。
+ */
+test('主循环全程 CLI:建书→写1章→两审→定稿→next 报第2章', async () => {
+  const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-cli-工作目录-'))
+  const run = (args, opts = {}) =>
+    exec(process.execPath, [BIN, ...args], { cwd: workdir, encoding: 'utf8', ...opts })
+  const runFail = async (args) => {
+    try {
+      await run(args)
+      return null
+    } catch (err) {
+      return err
+    }
+  }
+
+  try {
+    // 0. 模拟已安装的工作目录(安装器落位前的最小标记;init 集成随第 3 步任务补 CI 全链路)
+    await fs.mkdir(path.join(workdir, '.webnovel'), { recursive: true })
+
+    // 1. 空工作目录 next --json → 序1 建书引导(经真 bin,不是进程内)
+    const r1 = await run(['next', '--json'])
+    const s1 = JSON.parse(r1.stdout)
+    assert.equal(s1.序, 1)
+    assert.equal(s1.state, 'create-book')
+
+    // 2. 建书(persist-book --file)→ 登记为当前书
+    await fs.writeFile(
+      path.join(workdir, '建书.json'),
+      JSON.stringify({
+        book: { spec_version: '7.0', 书名: '青云试剑', 卷规模: 40, 体检周期: 50 },
+        总纲: '# 总纲\n## 结局\n林晚登顶。',
+        卷纲: '# 第1卷\n入门与试炼。',
+      }),
+      'utf8'
+    )
+    const r2 = await run(['persist-book', '--file=建书.json'])
+    assert.ok(r2.stdout.includes('已登记为当前书'), r2.stdout)
+    const repo = path.join(workdir, '青云试剑')
+    await fs.access(path.join(repo, 'book.yaml'))
+    await fs.access(path.join(repo, 'AGENTS.md'))
+
+    // 3. list-books 看到当前书
+    const r3 = await run(['list-books'])
+    assert.ok(r3.stdout.includes('青云试剑'))
+
+    // 4. next --json → 序6 起草第 1 章(建书产物已随 init commit,不误触序2 手改)
+    const r4 = await run(['next', '--json'])
+    const s4 = JSON.parse(r4.stdout)
+    assert.equal(s4.序, 6, `建书后应序6,实际:${r4.stdout}`)
+    assert.equal(s4.dto.nextChapter, 1)
+
+    // 5. 细纲回流(persist-outline --file)
+    await fs.writeFile(
+      path.join(workdir, '细纲.json'),
+      JSON.stringify({ 细纲: '## 本章要写到的事\n林晚初入青云宗,得玉佩。\n' }),
+      'utf8'
+    )
+    await run(['persist-outline', `--file=${path.join(workdir, '细纲.json')}`])
+    await fs.access(path.join(repo, '工作区', '细纲.md'))
+
+    // 6. 备料 + 草稿 + 机检(草稿是 AI 产物,落工作区不经 CLI)
+    const r6 = await run(['prepare-chapter', '1'])
+    assert.ok(r6.stdout.includes('本章写作材料'), r6.stdout)
+    await fs.writeFile(
+      path.join(repo, '工作区', '草稿-A.md'),
+      '---\n章号: 1\n标题: 初入青云\n---\n林晚背着行囊踏入青云宗山门,腰间玉佩微微发烫。',
+      'utf8'
+    )
+    const r6b = await run(['mechanical-check', '1'])
+    const mc = JSON.parse(r6b.stdout)
+    assert.ok('pass' in mc && Array.isArray(mc.issues))
+
+    // 7. 审稿输入 → 两审(桩产物)入库
+    const r7 = await run(['review-input', '1'])
+    assert.ok(r7.stdout.includes('审稿输入.json'))
+    const input = JSON.parse(await fs.readFile(path.join(repo, '工作区', '审稿输入.json'), 'utf8'))
+    assert.equal(input.章号, 1)
+    await fs.writeFile(
+      path.join(workdir, '两审.json'),
+      JSON.stringify({
+        事实审查: { chapter: 1, issues: [] },
+        编辑审: { chapter: 1, issues: [] },
+        章摘要: '林晚入宗,玉佩异动。',
+      }),
+      'utf8'
+    )
+    const r7b = await run(['save-review', '1', `--file=${path.join(workdir, '两审.json')}`])
+    assert.ok(r7b.stdout.includes('0 个阻断'), r7b.stdout)
+    await fs.access(path.join(repo, '工作区', '审稿.md'))
+
+    // 8. 定稿(finalize --payload)→ 正文入档、工作区清理
+    await fs.writeFile(
+      path.join(workdir, '定稿包.json'),
+      JSON.stringify({
+        frontMatter: { 章号: 1, 标题: '初入青云', 卷: 1, 字数: 30, 章定位: '铺垫', 钩子: '悬念钩-中', 情绪定位: '铺垫' },
+        body: '林晚背着行囊踏入青云宗山门,腰间玉佩微微发烫。',
+        summary: '林晚入宗,玉佩异动。',
+        commitLines: {},
+        workspaceFiles: ['工作区/草稿-A.md', '工作区/细纲.md', '工作区/审稿输入.json', '工作区/审稿.md', '工作区/本章写作材料.md'],
+      }),
+      'utf8'
+    )
+    const r8 = await run(['finalize', '1', `--payload=${path.join(workdir, '定稿包.json')}`])
+    assert.ok(r8.stdout.includes('已定稿'), r8.stdout)
+    await fs.access(path.join(repo, '定稿', '正文', '0001-初入青云.md'))
+    await fs.access(path.join(repo, '定稿', '摘要', '章摘要', '0001.md'))
+    await assert.rejects(fs.access(path.join(repo, '工作区', '草稿-A.md')), '定稿后草稿应清理')
+
+    // 9. next --json → 序6 起草第 2 章(定稿刷新缓存,不重抄第 1 章)
+    const r9 = await run(['next', '--json'])
+    const s9 = JSON.parse(r9.stdout)
+    assert.equal(s9.序, 6, `定稿后应序6,实际:${r9.stdout}`)
+    assert.equal(s9.dto.nextChapter, 2, '定稿后 next 应起草第 2 章(不重抄)')
+
+    // 10. 换书人话报错路径:switch-book 不存在的书 → 退出码非零 + 候选
+    const err = await runFail(['switch-book', '不存在的书'])
+    assert.ok(err, '应失败退出')
+    assert.ok(String(err.stderr).includes('青云试剑'), `候选应含现有书:${err.stderr}`)
+  } finally {
+    await fs.rm(workdir, { recursive: true, force: true })
+  }
+})