Browse Source

feat(v7): M4 review P1——两审/会话/校验稳健性(含 deep P1-6/P1-7)

按 review backlog P1 段修七条缺口:

- P1-1 assembleReviewInput 补全:相关条目(进行中条目)+ 名册快照 + aliasMap
  (名册 entity_aliases 规范源 + 角色卡 fm.别名),草稿用别名也命中角色(此前只认正名)。
- P1-2 validateReviewReport 坏输入安全:issues 元素 null/字符串/数组先报错不抛;
  blocking 只认严格布尔 === true(字符串 "true"/"false" 不当真),critical/unregistered_thread
  覆盖规则不变。
- P1-3 原始 vs 归一化分存:runReviews 传 raw,persistReviewReport 额外落
  事实审查.raw.json / 编辑审.raw.json(原子批),保留模型原话便于回溯漂移。
- P1-4 session 自愈回写:新增 writeBooksRegistry;books.jsonl 部分损坏丢坏行回写、
  缺失重建回写(best-effort,不阻断会话),不再长期半坏。
- P1-5 validator 绝对路径检测扩展:盘符(C:\ C:/)+ UNC(\host)+ Unix 绝对路径
  (/tmp /opt /root /mnt ...),避开 URL scheme 与 and/or 误判。
- P1-6(deep) goto-chapter:入口加 checkGitHealth;confirm 前 status 查脏树(定稿/大纲)
  → 拒绝 reset 并提示先 commit/stash(rescue ref 不含工作树,手改不丢)。
- P1-7(deep) finalize 回滚收窄:从 定稿/+大纲/ 整棵子树收窄到本次 written 文件集合,
  逐文件 restore(未跟踪新章静默失败)+ clean 删未跟踪,不再误伤同子树其他章手改;
  git.clean 内包 try/catch(与 restore 一致,Windows 文件锁不破坏 {ok,error} 契约)。

测试:263 绿(+11 新);drift check 通过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lingfengQAQ 18 hours ago
parent
commit
317fc60caf

+ 7 - 7
.trellis/tasks/06-27-m4-ai-roles/implement.md

@@ -105,13 +105,13 @@ merged 收敛版之外的 deep 报告独有项(作者手改丢失、回滚范
 
 
 ### P1 两审 / 会话 / 校验
 ### P1 两审 / 会话 / 校验
 
 
-- [ ] P1-1 `src/review/index.js` + `src/dto/character-context.js`:补全 ReviewInput,把相关条目、新专名、别名命中的角色都带进去。
-- [ ] P1-2 `src/review/schema.js`:坏输入先判类型,`blocking` 只收明确布尔,别靠真值强转。
-- [ ] P1-3 `src/review/index.js`:原始审稿结果和归一化结果分开保存,方便回溯模型原话。
-- [ ] P1-4 `src/session/index.js`:`books.jsonl` 部分损坏也触发自愈,必要时回写修复结果。
-- [ ] P1-5 `src/host-shells/validator.js`:扩展绝对路径检测,把常见 Windows / Linux / UNC 形式都算进去。
-- [ ] P1-6 (deep) `src/state-machine/flows/goto-chapter.js`:`--confirm` 走 `reset --hard` 前先 `git stash` 或拒绝脏树;现有 rescue ref 只存 HEAD 指针不含工作树,作者未登记手改会被静默抹掉且无法找回。该 flow 也不跑 `checkGitHealth`。
-- [ ] P1-7 (deep) `src/finalize/index.js` + `src/finalize/git.js`:回滚范围从 `定稿/`+`大纲/` 整棵子树收窄到本次 `written` 文件集合,避免误伤同子树其他章的手改;`git.clean` 包 try/catch(Windows 文件锁抛错会逃出 catch 破坏 `{ok,error}` 契约,`restore` 已有 try)。
+- [x] P1-1 `src/review/index.js` + `src/dto/character-context.js`:补全 ReviewInput,把相关条目、新专名、别名命中的角色都带进去。✅ assembleReviewInput 加 相关条目(进行中条目)+ 名册快照 + aliasMap(名册 entity_aliases 规范源+角色卡 fm.别名),草稿用别名也命中。
+- [x] P1-2 `src/review/schema.js`:坏输入先判类型,`blocking` 只收明确布尔,别靠真值强转。✅ issues 元素 null/字符串/数组先报错不抛;`blocking === true` 严格布尔(critical/unregistered_thread 覆盖规则不变)。
+- [x] P1-3 `src/review/index.js`:原始审稿结果和归一化结果分开保存,方便回溯模型原话。✅ runReviews 传 raw,persistReviewReport 额外落 `事实审查.raw.json`/`编辑审.raw.json`(原子批)。
+- [x] P1-4 `src/session/index.js`:`books.jsonl` 部分损坏也触发自愈,必要时回写修复结果。✅ 新增 writeBooksRegistry;部分损坏丢坏行回写、缺失重建回写(best-effort,不阻断会话)。
+- [x] P1-5 `src/host-shells/validator.js`:扩展绝对路径检测,把常见 Windows / Linux / UNC 形式都算进去。✅ ABS_PATH 覆盖盘符(C:\ C:/)+UNC(\\host)+Unix 绝对路径(/tmp /opt /root /mnt ...),避开 URL scheme 与 and/or 误判。
+- [x] P1-6 (deep) `src/state-machine/flows/goto-chapter.js`:`--confirm` 走 `reset --hard` 前先 `git stash` 或拒绝脏树;现有 rescue ref 只存 HEAD 指针不含工作树,作者未登记手改会被静默抹掉且无法找回。该 flow 也不跑 `checkGitHealth`。✅ 入口加 checkGitHealth;confirm 前 status 查脏树(定稿/大纲)→ 拒绝并提示先 commit/stash,手改不丢。
+- [x] P1-7 (deep) `src/finalize/index.js` + `src/finalize/git.js`:回滚范围从 `定稿/`+`大纲/` 整棵子树收窄到本次 `written` 文件集合,避免误伤同子树其他章的手改;`git.clean` 包 try/catch(Windows 文件锁抛错会逃出 catch 破坏 `{ok,error}` 契约,`restore` 已有 try)。✅ 回滚逐文件 restore(未跟踪新章静默失败)+ clean 删未跟踪;clean 内包 try。测试:第1章手改在 finalize 第3章断电回滚后保留。
 
 
 ### P2 spec 回填
 ### P2 spec 回填
 
 

+ 5 - 1
v7/src/finalize/git.js

@@ -39,7 +39,11 @@ export function createGit(repoPath) {
     },
     },
     /** 删除 paths 下未跟踪的新文件(scoped,绝不触及 工作区/) */
     /** 删除 paths 下未跟踪的新文件(scoped,绝不触及 工作区/) */
     async clean(paths) {
     async clean(paths) {
-      await run(['clean', '-fd', '--', ...paths])
+      try {
+        await run(['clean', '-fd', '--', ...paths])
+      } catch {
+        // 文件锁等致 clean 失败:尽力而为,不破坏 {ok,error} 契约(M3 git 健康检查兜底)
+      }
     },
     },
     async revCount() {
     async revCount() {
       try {
       try {

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

@@ -117,10 +117,15 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
 
 
     return { ok: true, commitHash, error: '' }
     return { ok: true, commitHash, error: '' }
   } catch (err) {
   } catch (err) {
-    // commit 前中断:回滚未提交写入(仅 定稿/大纲),工作区原样保留
+    // commit 前中断:回滚本次 written 集合(非整棵 定稿/大纲 子树,避免误伤同子树其他章手改)。
+    // written 在 try 外声明,catch 可见。逐文件 restore:新章文件未跟踪会让整条 restore
+    // 报错被吞,逐个跑才能精确复原已跟踪文件;clean 删本次新建的未跟踪文件。
+    const relFiles = [...new Set(written)].map((f) => path.relative(repoPath, f))
     try {
     try {
-      await git.restore(['定稿/', '大纲/'])
-      await git.clean(['定稿/', '大纲/'])
+      for (const rel of relFiles) {
+        await git.restore([rel])
+      }
+      await git.clean(relFiles)
     } catch {
     } catch {
       // 回滚尽力而为;M3 git 健康检查兜底
       // 回滚尽力而为;M3 git 健康检查兜底
     }
     }

+ 3 - 2
v7/src/host-shells/validator.js

@@ -5,8 +5,9 @@ import { generateHostShells } from './generate.js'
 
 
 /** package validator(多智能体 spec v3.4 §9):registry/support.md/description 预算/无本机绝对路径 */
 /** package validator(多智能体 spec v3.4 §9):registry/support.md/description 预算/无本机绝对路径 */
 
 
-// windows 盘符路径 或 unix 用户目录绝对路径
-const ABS_PATH = /(?:[A-Za-z]:\\)|(?:\/(?:Users|home)\/)/
+// 本机绝对路径:Windows 盘符(C:\ / C:/) | UNC(\\host) | Unix 绝对路径(/tmp/x /opt/foo /root/bar /mnt/d ...)
+// 盘符前加 (?<![A-Za-z]) 避开 URL scheme(https://)误判;Unix 段以字母起、2+ 段,避开 and/or 这类
+const ABS_PATH = /(?<![A-Za-z])[A-Za-z]:[\\/]|\\{2}[^\\]|(?<![\w/])\/[A-Za-z][\w-]*(?:\/[A-Za-z][\w-]*)+/
 const CODEX_DESC_BUDGET = 8192
 const CODEX_DESC_BUDGET = 8192
 
 
 export async function validatePackage(baseDir) {
 export async function validatePackage(baseDir) {

+ 60 - 10
v7/src/review/index.js

@@ -34,17 +34,39 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
     const status = await assembleBookStatus(ctx)
     const status = await assembleBookStatus(ctx)
     const 当前卷 = status.ok ? status.data.当前卷 : 1
     const 当前卷 = status.ok ? status.data.当前卷 : 1
 
 
-    // 相关角色:扫角色目录,正名出现在草稿里的纳入(不依赖缓存 schema)
+    // P1-1:名册快照(正名+别名),供 AI 判新专名,不泄漏路径;同时建 aliasMap 供角色别名命中
+    let 名册 = []
+    const aliasMap = new Map() // 正名 → 别名[]
+    try {
+      const rows = await cache.query(
+        "SELECT e.id AS 正名, group_concat(a.alias, ',') AS 别名从句 FROM entities e LEFT JOIN entity_aliases a ON a.entity_id = e.id WHERE e.type = 'character' GROUP BY e.id"
+      )
+      名册 = rows.map((r) => {
+        const 别名 = r.别名从句 ? r.别名从句.split(',').map((s) => s.trim()).filter(Boolean) : []
+        if (别名.length) aliasMap.set(r.正名, 别名)
+        return { 正名: r.正名, 别名 }
+      })
+    } catch {
+      // 缓存不可用则略
+    }
+
+    // 相关角色:扫角色目录,正名或别名出现在草稿里的纳入(P1-1:别名也要命中)
     const 相关角色 = []
     const 相关角色 = []
     try {
     try {
       const dir = path.join(repoPath, '定稿', '设定', '角色')
       const dir = path.join(repoPath, '定稿', '设定', '角色')
       for (const f of await fs.readdir(dir)) {
       for (const f of await fs.readdir(dir)) {
         if (!f.endsWith('.md')) continue
         if (!f.endsWith('.md')) continue
         const name = f.replace(/\.md$/, '')
         const name = f.replace(/\.md$/, '')
-        if (草稿全文.includes(name)) {
-          const cc = await assembleCharacterContext(ctx, name)
-          if (cc.ok) 相关角色.push(cc.context)
-        }
+        const cc = await assembleCharacterContext(ctx, name)
+        if (!cc.ok) continue
+        // 别名合集:名册 entity_aliases(规范源)+ 角色卡 fm.别名
+        const aliases = new Set(aliasMap.get(name) || [])
+        const fmAliases = cc.context.别名
+        if (Array.isArray(fmAliases)) fmAliases.forEach((a) => a && aliases.add(a))
+        else if (typeof fmAliases === 'string')
+          fmAliases.split(',').forEach((a) => a.trim() && aliases.add(a.trim()))
+        const hit = 草稿全文.includes(name) || [...aliases].some((a) => a && 草稿全文.includes(a))
+        if (hit) 相关角色.push(cc.context)
       }
       }
     } catch {
     } catch {
       // 无角色目录
       // 无角色目录
@@ -56,6 +78,25 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
     const secrets = await new SecretReader(repoPath, cache).listUnrevealed()
     const secrets = await new SecretReader(repoPath, cache).listUnrevealed()
     const 信息差候选 = secrets.map((s) => ({ id: s.id, 关键词: s.关键词 ?? s.keyword ?? '' }))
     const 信息差候选 = secrets.map((s) => ({ id: s.id, 关键词: s.关键词 ?? s.keyword ?? '' }))
 
 
+    // P1-1:相关条目 = 仍在进行、且在本章前开启的条目(不泄漏 file_path)
+    let 相关条目 = []
+    try {
+      const rows = await cache.query(
+        "SELECT id, type, short_title, status, opened_chapter, last_advanced_chapter FROM threads WHERE status = '进行' AND opened_chapter <= ? ORDER BY opened_chapter",
+        [chapterNum]
+      )
+      相关条目 = rows.map((r) => ({
+        id: r.id,
+        type: r.type,
+        简述: r.short_title,
+        状态: r.status,
+        开启章: r.opened_chapter,
+        最后推进章: r.last_advanced_chapter,
+      }))
+    } catch {
+      // 缓存不可用则略
+    }
+
     return {
     return {
       ok: true,
       ok: true,
       input: {
       input: {
@@ -64,6 +105,8 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
         本章要写到的事,
         本章要写到的事,
         全书近况: status.ok ? status.markdown : '',
         全书近况: status.ok ? status.markdown : '',
         相关角色,
         相关角色,
+        相关条目,
+        名册,
         时间线片段,
         时间线片段,
         信息差候选,
         信息差候选,
       },
       },
@@ -102,7 +145,7 @@ export function mergeReviews({ factCheck, editorial }, { mode, chapterNum }) {
  * @param {{repoPath}} ctx
  * @param {{repoPath}} ctx
  * @param {{chapterNum, merged, draft, 待确认新专名?: string[], 章摘要?: string}} args
  * @param {{chapterNum, merged, draft, 待确认新专名?: string[], 章摘要?: string}} args
  */
  */
-export async function persistReviewReport(ctx, { chapterNum, merged, draft, 待确认新专名 = [], 章摘要 = '' }) {
+export async function persistReviewReport(ctx, { chapterNum, merged, draft, 待确认新专名 = [], 章摘要 = '', raw = null }) {
   const { repoPath } = ctx
   const { repoPath } = ctx
 
 
   const issueLines = merged.issues.length
   const issueLines = merged.issues.length
@@ -131,12 +174,18 @@ export async function persistReviewReport(ctx, { chapterNum, merged, draft, 待
     '',
     '',
   ].join('\n')
   ].join('\n')
 
 
-  // P0-3:多文件原子落盘(事实审查/编辑审/审稿单要么全成要么原样)
-  await writeAtomicBatch(repoPath, [
+  // P0-3:多文件原子落盘;P1-3:原始输出与归一化结果分存,便于回溯模型原话
+  const files = [
     { path: path.join('工作区', '评审报告', '事实审查.json'), content: JSON.stringify(merged.事实审查 ?? {}, null, 2) },
     { path: path.join('工作区', '评审报告', '事实审查.json'), content: JSON.stringify(merged.事实审查 ?? {}, null, 2) },
     { path: path.join('工作区', '评审报告', '编辑审.json'), content: JSON.stringify(merged.编辑审 ?? {}, null, 2) },
     { path: path.join('工作区', '评审报告', '编辑审.json'), content: JSON.stringify(merged.编辑审 ?? {}, null, 2) },
-    { path: path.join('工作区', '审稿.md'), content: md },
-  ])
+  ]
+  if (raw) {
+    files.push({ path: path.join('工作区', '评审报告', '事实审查.raw.json'), content: JSON.stringify(raw.factCheck ?? {}, null, 2) })
+    files.push({ path: path.join('工作区', '评审报告', '编辑审.raw.json'), content: JSON.stringify(raw.editorial ?? {}, null, 2) })
+  }
+  files.push({ path: path.join('工作区', '审稿.md'), content: md })
+
+  await writeAtomicBatch(repoPath, files)
 
 
   const 审稿路径 = path.join(repoPath, '工作区', '审稿.md')
   const 审稿路径 = path.join(repoPath, '工作区', '审稿.md')
   return { ok: true, 审稿路径, error: '' }
   return { ok: true, 审稿路径, error: '' }
@@ -166,6 +215,7 @@ export async function runReviews(ctx, { chapterNum, draftPath, mode = 'complete'
     draft: inp.input.草稿全文,
     draft: inp.input.草稿全文,
     待确认新专名,
     待确认新专名,
     章摘要,
     章摘要,
+    raw: { factCheck: rawFact, editorial: rawEdit },
   })
   })
   return { ok: true, merged, 审稿路径: saved.审稿路径, errors: [] }
   return { ok: true, merged, 审稿路径: saved.审稿路径, errors: [] }
 }
 }

+ 7 - 2
v7/src/review/schema.js

@@ -49,6 +49,11 @@ export function validateReviewReport(report, { reviewType } = {}) {
 
 
   const normalized = report.issues.map((issue, i) => {
   const normalized = report.issues.map((issue, i) => {
     const where = `issues[${i}]`
     const where = `issues[${i}]`
+    // P1-2:坏输入(null/字符串/非对象)先判类型,别直接读字段抛异常
+    if (issue == null || typeof issue !== 'object' || Array.isArray(issue)) {
+      errors.push(`${where} 必须是对象,实际:${issue === null ? 'null' : Array.isArray(issue) ? 'array' : typeof issue}`)
+      return { ...issue, severity: '', category: '', location: '', description: '', evidence: '', fix_hint: '', blocking: false }
+    }
     if (!SEVERITIES.includes(issue.severity)) errors.push(`${where} severity 非法:${issue.severity}`)
     if (!SEVERITIES.includes(issue.severity)) errors.push(`${where} severity 非法:${issue.severity}`)
     if (allowed && !allowed.includes(issue.category)) {
     if (allowed && !allowed.includes(issue.category)) {
       errors.push(`${where} category「${issue.category}」越界(${reviewType})`)
       errors.push(`${where} category「${issue.category}」越界(${reviewType})`)
@@ -56,8 +61,8 @@ export function validateReviewReport(report, { reviewType } = {}) {
     for (const f of REQUIRED_FIELDS) {
     for (const f of REQUIRED_FIELDS) {
       if (issue[f] == null || issue[f] === '') errors.push(`${where} 缺字段 ${f}`)
       if (issue[f] == null || issue[f] === '') errors.push(`${where} 缺字段 ${f}`)
     }
     }
-    // 阻断规则
-    let blocking = !!issue.blocking
+    // 阻断规则:只认严格布尔 true,字符串 "false" 不当真(P1-2)
+    let blocking = issue.blocking === true
     if (issue.severity === 'critical') blocking = true
     if (issue.severity === 'critical') blocking = true
     if (issue.category === 'unregistered_thread') blocking = false
     if (issue.category === 'unregistered_thread') blocking = false
     return { ...issue, blocking }
     return { ...issue, blocking }

+ 30 - 1
v7/src/session/index.js

@@ -31,6 +31,19 @@ export async function readBooksRegistry(workdir) {
   return { ok: true, missing: false, books, corrupt }
   return { ok: true, missing: false, books, corrupt }
 }
 }
 
 
+/**
+ * 自愈回写:把有效书单写回 .webnovel/books.jsonl(P1-4)。
+ * 仅用于丢坏行 / 落扫描重建结果,不是 M5 的登记/换书写侧——那归 M5。
+ * 失败不阻断会话(best-effort)。
+ */
+export async function writeBooksRegistry(workdir, books) {
+  const dir = path.join(workdir, '.webnovel')
+  await fs.mkdir(dir, { recursive: true })
+  const p = path.join(dir, 'books.jsonl')
+  const content = books.map((b) => JSON.stringify(b)).join('\n') + '\n'
+  await fs.writeFile(p, content, 'utf8')
+}
+
 /** 扫工作目录子目录,含 book.yaml 的重建书单(spec §0 可重建)。当前书标记缺失 → 需作者选一次 */
 /** 扫工作目录子目录,含 book.yaml 的重建书单(spec §0 可重建)。当前书标记缺失 → 需作者选一次 */
 export async function scanRebuildBooks(workdir) {
 export async function scanRebuildBooks(workdir) {
   let entries
   let entries
@@ -58,12 +71,28 @@ export async function assembleSessionContext(workdir) {
   let reg = await readBooksRegistry(workdir)
   let reg = await readBooksRegistry(workdir)
   let rebuilt = false
   let rebuilt = false
   let needsAuthorPick = false
   let needsAuthorPick = false
+  let toWrite = null // P1-4:自愈回写载体
+
   if (!reg.ok || reg.missing || reg.books.length === 0) {
   if (!reg.ok || reg.missing || reg.books.length === 0) {
     const scan = await scanRebuildBooks(workdir)
     const scan = await scanRebuildBooks(workdir)
-    reg = { books: scan.books }
+    reg = { ok: true, missing: false, books: scan.books, corrupt: 0 }
     rebuilt = true
     rebuilt = true
     needsAuthorPick = scan.needsAuthorPick
     needsAuthorPick = scan.needsAuthorPick
+    // 扫描重建结果回写,下个会话不必再扫
+    if (scan.books.length) toWrite = scan.books
+  } else if (reg.corrupt > 0) {
+    // 部分损坏:丢坏行,把好行回写(自愈,不再长期半坏)
+    toWrite = reg.books
   }
   }
+
+  if (toWrite) {
+    try {
+      await writeBooksRegistry(workdir, toWrite)
+    } catch {
+      // 回写失败不阻断会话(best-effort)
+    }
+  }
+
   const current = reg.books.find((b) => b.当前) || null
   const current = reg.books.find((b) => b.当前) || null
   const names = reg.books.map((b) => b.书名).join('、')
   const names = reg.books.map((b) => b.书名).join('、')
   const text = [
   const text = [

+ 26 - 2
v7/src/state-machine/flows/goto-chapter.js

@@ -1,4 +1,5 @@
 import { createGit } from '../../finalize/git.js'
 import { createGit } from '../../finalize/git.js'
+import { checkGitHealth } from '../git-health.js'
 
 
 /**
 /**
  * 回到第 N 章(spec §9,git 回滚包装)。执行前展示影响范围 + 作者确认;
  * 回到第 N 章(spec §9,git 回滚包装)。执行前展示影响范围 + 作者确认;
@@ -10,8 +11,11 @@ export async function gotoChapter(ctx, { chapterNum, confirm = false } = {}) {
   if (!Number.isInteger(chapterNum)) return { ok: false, error: '请指定要回到的章号' }
   if (!Number.isInteger(chapterNum)) return { ok: false, error: '请指定要回到的章号' }
   const git = createGit(ctx.repoPath)
   const git = createGit(ctx.repoPath)
 
 
+  // P1-6:先跑 git 健康检查(此前漏跑,与状态机主入口一致)
+  const gitHealth = await checkGitHealth(ctx)
+
   const hash = await git.findChapterCommit(chapterNum)
   const hash = await git.findChapterCommit(chapterNum)
-  if (!hash) return { ok: false, error: `未找到第 ${chapterNum} 章的定稿提交(ch(${chapterNum}):)` }
+  if (!hash) return { ok: false, error: `未找到第 ${chapterNum} 章的定稿提交(ch(${chapterNum}):)`, gitHealth }
 
 
   const willLose = await git.commitsAfter(hash)
   const willLose = await git.commitsAfter(hash)
   if (!confirm) {
   if (!confirm) {
@@ -20,6 +24,7 @@ export async function gotoChapter(ctx, { chapterNum, confirm = false } = {}) {
       needsConfirm: true,
       needsConfirm: true,
       target: hash,
       target: hash,
       willLose,
       willLose,
+      gitHealth,
       message:
       message:
         willLose.length === 0
         willLose.length === 0
           ? `第 ${chapterNum} 章已是最新,无需回退。`
           ? `第 ${chapterNum} 章已是最新,无需回退。`
@@ -27,6 +32,24 @@ export async function gotoChapter(ctx, { chapterNum, confirm = false } = {}) {
     }
     }
   }
   }
 
 
+  // P1-6:reset --hard 前检查脏树。rescue ref 只存 HEAD 指针不含工作树,
+  // 定稿/大纲 若有未提交手改会被静默抹掉且无法找回 → 拒绝,要作者先 commit/stash。
+  const status = await git.status()
+  const dirtyScoped = status
+    .split('\n')
+    .filter(Boolean)
+    .some((l) => {
+      const p = l.slice(3)
+      return p.startsWith('定稿') || p.startsWith('大纲')
+    })
+  if (dirtyScoped) {
+    return {
+      ok: false,
+      error: '定稿/大纲 有未登记的手改,reset --hard 会丢弃且无法从救援 ref 找回。请先 commit 或 stash 再回退。',
+      gitHealth,
+    }
+  }
+
   const ref = `rescue/goto-${Date.now()}`
   const ref = `rescue/goto-${Date.now()}`
   try {
   try {
     await git.createBackupRef(ref) // 备份当前 HEAD
     await git.createBackupRef(ref) // 备份当前 HEAD
@@ -35,9 +58,10 @@ export async function gotoChapter(ctx, { chapterNum, confirm = false } = {}) {
       ok: true,
       ok: true,
       reverted: true,
       reverted: true,
       backupRef: `refs/${ref}`,
       backupRef: `refs/${ref}`,
+      gitHealth,
       message: `已回到第 ${chapterNum} 章。原状态已备份到 refs/${ref},如需找回:git reset --hard refs/${ref}`,
       message: `已回到第 ${chapterNum} 章。原状态已备份到 refs/${ref},如需找回:git reset --hard refs/${ref}`,
     }
     }
   } catch (err) {
   } catch (err) {
-    return { ok: false, error: `回退失败:${err.message}` }
+    return { ok: false, error: `回退失败:${err.message}`, gitHealth }
   }
   }
 }
 }

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

@@ -101,3 +101,22 @@ test('finalizeChapter 定稿后删 .cache 全量重建一致(不变量 2)',
     await cleanup()
     await cleanup()
   }
   }
 })
 })
+
+test('finalizeChapter 断电回滚(P1-7):不误伤同子树其他章的未提交手改', async () => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    // 在已跟踪的第1章上手改(不提交)——不在本次 written 集合里
+    const ch1 = path.join(ctx.repoPath, '定稿/正文/0001-开局.md')
+    await fs.writeFile(ch1, (await fs.readFile(ch1, 'utf8')) + '\n第1章手改。', 'utf8')
+
+    const r = await finalizeChapter(ctx, payload(), { faultAfterWrite: true })
+    assert.equal(r.ok, false)
+
+    // 本次新写的第3章被清(未跟踪 → clean)
+    await assert.rejects(() => fs.access(path.join(ctx.repoPath, '定稿/正文/0003-初露.md')))
+    // 第1章手改保留(回滚范围收窄到 written,不再整棵 定稿/ 子树)
+    assert.ok((await fs.readFile(ch1, 'utf8')).includes('第1章手改。'), '第1章手改不应被回滚抹掉')
+  } finally {
+    await cleanup()
+  }
+})

+ 29 - 0
v7/test/host-shells/validator.test.js

@@ -67,6 +67,35 @@ test('validatePackage:含本机绝对路径 → 报错', async () => {
   } finally { await cleanup() }
   } finally { await cleanup() }
 })
 })
 
 
+test('validatePackage(P1-5):扩展绝对路径检测 /tmp /opt /root UNC C:/ 都算', async () => {
+  const cases = [
+    '见 /tmp/draft.md',
+    '落在 /opt/webnovel/x',
+    '备份在 /root/book',
+    '挂载 /mnt/d/book',
+    'UNC \\\\nas\\\\share\\\\b.md',
+    '正斜杠盘符 C:/Users/x/a.md',
+  ]
+  for (const c of cases) {
+    const { root, w, cleanup } = await makePkg()
+    try {
+      await w('roles/事实审查.md', `---\nname: 事实审查\ndescription: d\n---\n${c}`)
+      const r = await validatePackage(root)
+      assert.equal(r.ok, false, `应检出绝对路径:${c}`)
+      assert.ok(r.errors.some((e) => e.includes('绝对路径')), c)
+    } finally { await cleanup() }
+  }
+})
+
+test('validatePackage(P1-5):URL scheme 与 prose 不误报', async () => {
+  const { root, w, cleanup } = await makePkg()
+  try {
+    await w('roles/事实审查.md', '---\nname: 事实审查\ndescription: d\n---\n参见 https://example.com/a/b 与 http://x.io 和 and/or 及 §3/4 节,无绝对路径。')
+    const r = await validatePackage(root)
+    assert.equal(r.ok, true, `URL/prose 不应误报绝对路径:${r.errors.join(';')}`)
+  } finally { await cleanup() }
+})
+
 test('driftCheck:真实 v7 确定性 + validator 通过', async () => {
 test('driftCheck:真实 v7 确定性 + validator 通过', async () => {
   const r = await driftCheck(V7)
   const r = await driftCheck(V7)
   assert.equal(r.ok, true, r.error)
   assert.equal(r.ok, true, r.error)

+ 47 - 0
v7/test/review/orchestration.test.js

@@ -104,3 +104,50 @@ test('runReviews:审稿单越界 category → ok=false 带错', async () => {
     assert.ok(r.errors.length > 0)
     assert.ok(r.errors.length > 0)
   } finally { await cleanup() }
   } finally { await cleanup() }
 })
 })
+
+test('P1-1:草稿用别名命中角色 + 名册/相关条目进 DTO', async () => {
+  // 名册有 林晚(正名)/晚晚(别名);草稿只用别名「晚晚」→ 旧逻辑漏,新逻辑应纳入
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n',
+    '定稿/正文/0001-起.md': chapter(1, '过去的事。'),
+    '定稿/设定/名册.md': '| 正名 | 别名 | 类型 | 首现章 |\n|------|------|------|--------|\n| 林晚 | 晚晚 | character | 1 |\n',
+    '定稿/设定/角色/林晚.md': charCard('林晚', '练气三层'),
+    '大纲/伏笔/伏笔-001-x.md': '---\n强度: 高\n状态: 进行\n开启章: 1\n---\n## 履历\n- 第1章:推进\n',
+    '工作区/细纲.md': '## 本章要写到的事\n晚晚突破。\n',
+    '工作区/草稿.md': '晚晚运转功法,突破到练气四层。她握紧青霜剑。',
+  })
+  try {
+    const r = await assembleReviewInput(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md' })
+    assert.equal(r.ok, true, r.error)
+    // 草稿只用别名「晚晚」,正名「林晚」没出现 → 必须靠别名命中
+    assert.ok(!r.input.草稿全文.includes('林晚'), '前置:草稿确实不含正名')
+    assert.ok(r.input.相关角色.some((c) => c.正名 === '林晚'), '别名命中应纳入林晚')
+    assert.ok(r.input.名册.some((m) => m.正名 === '林晚' && m.别名.includes('晚晚')), '名册带别名')
+    assert.ok(r.input.相关条目.some((t) => t.id?.startsWith('伏笔-001')), '相关条目带进行中的伏笔')
+    // 不泄漏路径
+    const json = JSON.stringify(r.input)
+    assert.ok(!json.includes(ctx.repoPath) && !json.includes('定稿/设定'))
+  } finally { await cleanup() }
+})
+
+test('P1-3:原始输出与归一化结果分存(.raw.json 保留模型原话)', async () => {
+  const { ctx, cleanup, root } = await makeReviewBook()
+  try {
+    // stub 返回 critical+blocking:false;归一化会把 blocking 改 true,raw 保留 false
+    const reviewers = {
+      factCheck: async (input) => ({
+        chapter: input.章号,
+        ai_meta: 'raw-marker',
+        issues: [fcIssue({ severity: 'critical', blocking: false })],
+      }),
+      editorial: async () => ({ issues: [] }),
+    }
+    const r = await runReviews(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md', mode: 'complete', reviewers })
+    assert.equal(r.ok, true)
+    const raw = JSON.parse(await fs.readFile(path.join(root, '工作区', '评审报告', '事实审查.raw.json'), 'utf8'))
+    const norm = JSON.parse(await fs.readFile(path.join(root, '工作区', '评审报告', '事实审查.json'), 'utf8'))
+    assert.equal(raw.issues[0].blocking, false, 'raw 保留 AI 原话 blocking:false')
+    assert.equal(norm.issues[0].blocking, true, '归一化把 critical 改 blocking:true')
+    assert.equal(raw.ai_meta, 'raw-marker', 'raw 保留 AI 额外字段')
+  } finally { await cleanup() }
+})

+ 27 - 0
v7/test/review/schema.test.js

@@ -66,3 +66,30 @@ test('issues 非数组 → ok=false 不抛', async () => {
   assert.equal(r.ok, false)
   assert.equal(r.ok, false)
   assert.equal(r.report, null)
   assert.equal(r.report, null)
 })
 })
+
+test('P1-2:issues 元素为 null/字符串/数组 → 报错不抛', () => {
+  const r = validateReviewReport(
+    { chapter: 5, issues: [null, '字符串', [1, 2], issue()] },
+    { reviewType: 'factCheck' }
+  )
+  assert.equal(r.ok, false)
+  assert.ok(r.errors.some((e) => e.includes('issues[0]')), '应报 issues[0] 非对象')
+  assert.ok(r.errors.some((e) => e.includes('issues[1]')), '应报 issues[1] 非对象')
+  assert.ok(r.errors.some((e) => e.includes('issues[2]')), '应报 issues[2] 非对象')
+})
+
+test('P1-2:blocking 只认严格布尔 true,字符串 "true"/"false" 不当真', () => {
+  const r = validateReviewReport(
+    { chapter: 5, issues: [issue({ severity: 'high', blocking: 'true' })] },
+    { reviewType: 'factCheck' }
+  )
+  assert.equal(r.ok, true)
+  assert.equal(r.report.issues[0].blocking, false, '字符串 "true" 不应转为阻断')
+  assert.equal(r.report.blocking_count, 0)
+
+  const rc = validateReviewReport(
+    { chapter: 5, issues: [issue({ severity: 'critical', blocking: 'false' })] },
+    { reviewType: 'factCheck' }
+  )
+  assert.equal(rc.report.issues[0].blocking, true, 'critical 强制阻断覆盖')
+})

+ 47 - 0
v7/test/session/session.test.js

@@ -7,6 +7,7 @@ import {
   readBooksRegistry,
   readBooksRegistry,
   scanRebuildBooks,
   scanRebuildBooks,
   assembleSessionContext,
   assembleSessionContext,
+  writeBooksRegistry,
 } from '../../src/session/index.js'
 } from '../../src/session/index.js'
 
 
 async function tmpWorkdir() {
 async function tmpWorkdir() {
@@ -85,6 +86,52 @@ test('assembleSessionContext:登记缺失 → 扫描重建并标记', async ()
   } finally { await cleanup() }
   } finally { await cleanup() }
 })
 })
 
 
+test('assembleSessionContext(P1-4):部分损坏 → 丢坏行回写,下读 corrupt=0', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await writeRegistry(root, [
+      JSON.stringify({ 书名: '剑起青云', 目录: '剑起青云', 当前: true }),
+      '{坏的 json',
+      JSON.stringify({ 书名: '星海', 目录: '星海', 当前: false }),
+    ])
+    const r = await assembleSessionContext(root)
+    assert.equal(r.ok, true)
+    assert.equal(r.current.书名, '剑起青云')
+    // 回写后坏行已丢
+    const reread = await readBooksRegistry(root)
+    assert.equal(reread.corrupt, 0, '自愈回写应丢掉坏行')
+    assert.equal(reread.books.length, 2)
+  } finally { await cleanup() }
+})
+
+test('assembleSessionContext(P1-4):登记缺失重建后回写,下读不再 missing', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await makeBookDir(root, '剑起青云')
+    await makeBookDir(root, '星海')
+    const r = await assembleSessionContext(root)
+    assert.equal(r.rebuilt, true)
+    assert.equal(r.books.length, 2)
+    // 回写后下个会话直接读登记,不必再扫
+    const reread = await readBooksRegistry(root)
+    assert.equal(reread.missing, false)
+    assert.equal(reread.books.length, 2)
+  } finally { await cleanup() }
+})
+
+test('writeBooksRegistry:写 JSONL 逐行,可被 readBooksRegistry 读回', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await writeBooksRegistry(root, [
+      { 书名: 'A', 目录: 'A', 当前: true },
+      { 书名: 'B', 目录: 'B', 当前: false },
+    ])
+    const r = await readBooksRegistry(root)
+    assert.equal(r.books.length, 2)
+    assert.equal(r.corrupt, 0)
+  } finally { await cleanup() }
+})
+
 test('无 hook 等价:hook 入口与状态机入口调同一函数 → 注入文本逐字一致', async () => {
 test('无 hook 等价:hook 入口与状态机入口调同一函数 → 注入文本逐字一致', async () => {
   const { root, cleanup } = await tmpWorkdir()
   const { root, cleanup } = await tmpWorkdir()
   try {
   try {

+ 19 - 0
v7/test/state-machine/flows/goto-chapter.test.js

@@ -53,3 +53,22 @@ test('回到第N章:不存在的章 → ok=false', async () => {
     await cleanup()
     await cleanup()
   }
   }
 })
 })
+
+test('P1-6:定稿有未登记手改 + confirm → 拒绝 reset(不丢手改)', async () => {
+  const { ctx, root, git, cleanup } = await bookWithChapters()
+  try {
+    // 在已跟踪的第1章上手改(不提交)
+    const path1 = path.join(root, '定稿/正文/0001-起.md')
+    await fs.writeFile(path1, (await fs.readFile(path1, 'utf8')) + '\n手改了一句。', 'utf8')
+    const r = await gotoChapter(ctx, { chapterNum: 1, confirm: true })
+    assert.equal(r.ok, false, '脏树应拒绝 reset')
+    assert.match(r.error, /手改/)
+    // 手改仍在,未被抹掉(rescue ref 不含工作树,拒绝才安全)
+    assert.ok((await fs.readFile(path1, 'utf8')).includes('手改了一句'), '手改应保留')
+    // 第2章也仍在(未 reset)
+    await fs.stat(path.join(root, '定稿/正文/0002-承.md'))
+    void git
+  } finally {
+    await cleanup()
+  }
+})