Jelajahi Sumber

fix(v7): M1-M5 review 三条支路成环缺口收口——卷复盘 commit、改源刷缓存、relink 补登通道

P1:persistVolumeReview 补 vol(NN) commit + 刷缓存;refreshCacheAfterSourceChange
统一助手接入 finalize/goto/retcon/卷复盘/修复回写;新增 relink --message 命令,
序2 message/dto 带变更清单,SKILL 序2/序3 接 dto。
P2:json-input 相对路径先 cwd 再书仓库并列出候选;--draft 绝对路径 resolve;
安装器清单以旧为底不丢未涉及宿主记录;persist-book 书名非法字符前置拦截;
建书/卷复盘空提交幂等;序3 dto 带断点明细;注释与并发注记修正。
测试:relay.test.js 六条流程接力用例 + json-input/installer/f1-seams 补测,
346 绿(基线 335+11),真 CLI 探针复跑三条 P1 全闭合。
lingfengQAQ 13 jam lalu
induk
melakukan
93fcac5000

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

@@ -72,6 +72,7 @@ if (!command || command === '--help') {
   console.log('  health-check                            体检:悬了太久/条目活跃率/连续弱钩,报告落工作区(文体项随 M5.5)')
   console.log('  impact <关键词>                          影响分析:哪些章建立在这个事实上(已发布/未发布)')
   console.log('  goto-chapter <章号> [--confirm]          回到第N章(先备份再回滚,作者不碰 git)')
+  console.log('  relink --message=<一句话说明>            补登手改:定稿/大纲 未登记改动入档(fix(手改))')
   process.exit(0)
 }
 

+ 3 - 1
v7/skills/webnovel-writer/SKILL.md

@@ -19,7 +19,9 @@ SessionStart 已注入「当前在写哪本 / 共几本 / 全书近况入口」
 作者说「继续」,运行 `{{cmd}} next --json`(git 健康检查先行),按返回的 `序` 执行:
 - 序0 修复确认:对 `dto.failures` 逐个给「保留作者意图」的修复方案,作者确认后写 `{"repairs":[{"file","content"}]}`,运行 `{{cmd}} persist-repair --file=<json路径>`。
 - 序1 建书:问答收集书名、类型、主角、金手指、结局,产出 `{"book","总纲","卷纲"}`,运行 `{{cmd}} persist-book --file=<json路径>`。
-- 序2 手改补登 / 序3 断点续跑 / 序5 体检:按返回的 `message` 指引执行。
+- 序2 手改补登:向作者出示 `dto.变更文件` 问「补登吗」,确认后运行 `{{cmd}} relink --message=<一句话说明>`。
+- 序3 断点续跑:按 `dto.从哪继续` 回到写章流程对应步骤。
+- 序5 体检:按返回的 `message` 指引执行。
 - 序4 卷复盘:吃 DTO 与作者对谈,产出 `{"卷号","卷摘要","下卷卷纲","伏笔条目"}`,运行 `{{cmd}} persist-volume-review --file=<json路径>`。
 - 序6 起草细纲:吃 DTO 拟细纲提案(本章定位声明 + 本章要写到的事 + 备选),作者确认后产出 `{"细纲"}`,运行 `{{cmd}} persist-outline --file=<json路径>`。
 

+ 14 - 0
v7/src/cache/index.js

@@ -6,6 +6,20 @@ import { rebuildCache } from './rebuilder.js'
 
 let rebuildCounter = 0
 
+/**
+ * 改源流程统一的缓存刷新(P1-2):finalize/goto/retcon/卷复盘/修复补登等改了源文件的路径,
+ * 成功尾部必须自刷——ensureReady 只兜「缺失/损坏」,不感知源变更。
+ * 失败不抛:作废旧缓存(防陈旧数据驱动后续判定),返回结果供上层如实上报,下次命令自动重建。
+ */
+export async function refreshCacheAfterSourceChange(ctx) {
+  if (!ctx.cache) return null
+  try {
+    return await ctx.cache.rebuildFromSource(ctx.repoPath, { keepExistingOnFailure: false })
+  } catch (err) {
+    return { ok: false, warnings: [], errors: [`缓存刷新失败:${err.message}`] }
+  }
+}
+
 /**
  * CacheManager:管理 .cache/index.db 五表。
  */

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

@@ -35,6 +35,14 @@ export async function run(args, options, ctx) {
   if (/[\\/]/.test(dirName) || dirName.includes('..') || dirName.startsWith('.')) {
     return { ok: false, error: `书目录名不合法:${dirName}(须是工作目录下的一层普通目录名)` }
   }
+  // Windows 目录名雷区前置拦截,不让 mkdir 深处报天书;书名本身不用改,指路 --dir
+  const hasControlChar = [...dirName].some((c) => c.charCodeAt(0) < 32)
+  if (/[<>:"|?*]/.test(dirName) || hasControlChar || /[. ]$/.test(dirName)) {
+    return {
+      ok: false,
+      error: `书名「${dirName}」含文件系统不支持的字符(< > : " | ? * 控制符,或结尾的点/空格),不能直接当目录名。书名不用改,加 --dir=<目录名> 指定一个不含这些字符的目录即可。`,
+    }
+  }
   const repoPath = path.join(ctx.workdir, dirName)
   try {
     await fs.access(path.join(repoPath, 'book.yaml'))

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

@@ -0,0 +1,35 @@
+import { createGit } from '../finalize/git.js'
+import { listManualEdits } from '../state-machine/detectors.js'
+import { refreshCacheAfterSourceChange } from '../cache/index.js'
+
+/**
+ * relink --message=<一句话说明>:序2 手改补登(spec §9/§10)。
+ * 把 定稿/大纲 下未登记的手改 add + commit(fix(手改) 前缀)并刷新缓存——作者不碰 git。
+ * 范围与序2 检测同源(listManualEdits),不会捎带工作区等无关路径。
+ * 契约:纯返回 {ok, output?, error?}。
+ */
+export async function run(args, options, ctx) {
+  const message = typeof options.message === 'string' ? options.message.trim() : ''
+  if (!message) {
+    return { ok: false, error: '缺少 --message=<一句话说明>(写进 commit,日后可查这次手改改了什么)' }
+  }
+  const changes = await listManualEdits(ctx.repoPath)
+  if (!changes.length) {
+    return { ok: true, output: '定稿/大纲 没有未登记的手改,无需补登。' }
+  }
+  const git = createGit(ctx.repoPath)
+  try {
+    await git.ensureIdentity()
+    await git.add(changes)
+    const hash = await git.commit(`fix(手改): ${message}`)
+    const cacheRefresh = await refreshCacheAfterSourceChange(ctx)
+    const cacheNote =
+      cacheRefresh && !cacheRefresh.ok ? '(缓存刷新失败,已作废旧缓存,下次命令自动重建)' : ''
+    return {
+      ok: true,
+      output: `已补登 ${changes.length} 处手改:fix(手改): ${message}(${hash.slice(0, 7)})${cacheNote}`,
+    }
+  } catch (err) {
+    return { ok: false, error: `补登失败:${err.message}` }
+  }
+}

+ 2 - 1
v7/src/commands/save-review.js

@@ -27,7 +27,8 @@ export async function run(args, options, ctx) {
     options.draft && options.draft !== true ? options.draft : path.join('工作区', '草稿-A.md')
   let draft
   try {
-    draft = await fs.readFile(path.join(ctx.repoPath, draftRel), 'utf8')
+    // resolve 而非 join:--draft 传绝对路径时 join 会拼出坏路径
+    draft = await fs.readFile(path.resolve(ctx.repoPath, draftRel), 'utf8')
   } catch (err) {
     return { ok: false, error: `读不到草稿 ${draftRel}:${err.message}(审稿单要附草稿原文)` }
   }

+ 2 - 7
v7/src/finalize/index.js

@@ -7,6 +7,7 @@ import { TimelineWriter } from '../storage/adapters/TimelineWriter.js'
 import { SecretWriter } from '../storage/adapters/SecretWriter.js'
 import { SummaryWriter } from '../storage/adapters/SummaryWriter.js'
 import { createGit } from './git.js'
+import { refreshCacheAfterSourceChange } from '../cache/index.js'
 
 /**
  * 定稿:原子 commit(D3)。写工作树 → git add → commit → 最后清工作区。
@@ -127,13 +128,7 @@ export async function finalizeChapter(ctx, payload, opts = {}) {
 
     // P0-1:定稿后同步刷新缓存,避免 next 读旧章号重抄本章。
     // 重建失败不阻断定稿(已 commit 入档),但不能继续保留旧缓存,否则 next 会读旧章号。
-    if (ctx.cache) {
-      try {
-        cacheRefresh = await ctx.cache.rebuildFromSource(repoPath, { keepExistingOnFailure: false })
-      } catch (err) {
-        cacheRefresh = { ok: false, warnings: [], errors: [`缓存刷新失败:${err.message}`] }
-      }
-    }
+    cacheRefresh = await refreshCacheAfterSourceChange(ctx)
 
     // 4. 清工作区(必须在 commit 成功之后)
     for (const wf of workspaceFiles) {

+ 4 - 4
v7/src/installer/index.js

@@ -64,18 +64,18 @@ export async function installWorkdir(ctx, { hostsOverride = null, force = false,
     return fail(`组装安装文件失败:${err.message}`)
   }
 
-  // 哈希三态写入
+  // 哈希三态写入。新清单以旧清单为底:本轮未涉及的宿主/文件保留旧记录,
+  // 否则下次全量 update 会把它们判成 new 而静默覆盖用户手改(违反 §8 不静默覆盖)
   const written = []
   const skipped = []
-  const newFiles = { }
+  const newFiles = { ...(manifest?.files || {}) }
   for (const [rel, content] of Object.entries(files)) {
     const full = path.join(workdir, rel)
     const disk = await readIfExists(full)
     const cls = classifyFile(rel, disk == null ? null : sha256(disk), manifest)
     const newHash = sha256(content)
     if (cls === 'user-modified' && !force) {
-      skipped.push(rel)
-      newFiles[rel] = manifest.files[rel] // 保留旧记录,下次仍能识别用户改动
+      skipped.push(rel) // 旧记录已在底表,下次仍能识别用户改动
       continue
     }
     if (disk !== content) {

+ 1 - 1
v7/src/prep/index.js

@@ -94,7 +94,7 @@ export async function prepareChapterMaterials(ctx, { chapterNum }) {
       '',
       反和解 ? `## 反和解规则\n${反和解}` : '## 反和解规则\n(无)',
       '',
-      '## 反复读清单\n(M2 暂空,跨章高频意象统计随 M3+ 体检补)',
+      '## 反复读清单\n(暂空,跨章高频意象统计随 M5.5 体检补)',
       '',
     ]
     const content = parts.join('\n')

+ 2 - 1
v7/src/review/index.js

@@ -24,7 +24,8 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
   try {
     const { repoPath, cache } = ctx
 
-    const 草稿全文 = await fs.readFile(path.join(repoPath, draftPath), 'utf8')
+    // resolve 而非 join:draftPath 传绝对路径时 join 会拼出坏路径
+    const 草稿全文 = await fs.readFile(path.resolve(repoPath, draftPath), 'utf8')
 
     // 拟条目变动:草稿 front matter 三数组声明(与机检共用同一解析,不双写)
     const draftFm = parseFrontMatter(草稿全文)

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

@@ -65,6 +65,7 @@ export async function scanRebuildBooks(workdir) {
 /**
  * 读书单并自愈:登记缺失/为空 → 扫描重建回写;坏行 → 丢弃回写好行。
  * SessionStart 注入、工作目录定位、list-books 共用本函数,自愈逻辑单源。
+ * 并发跑两条命令时自愈回写是 last-write-wins:登记可随时从各书 book.yaml 扫描重建,接受现状。
  */
 export async function loadBooks(workdir) {
   let reg = await readBooksRegistry(workdir)

+ 41 - 23
v7/src/state-machine/detectors.js

@@ -87,48 +87,66 @@ export async function bookMissing(repoPath) {
   }
 }
 
-/** 序2:定稿/大纲 有未登记手改(git 工作树有未提交改动) */
-export async function hasManualEdits(repoPath) {
+/** 序2:定稿/大纲 下未登记的手改清单(git 工作树未提交改动,含未跟踪新文件)。
+ * 供状态机出 dto 变更清单、relink 补登命令圈定 add 范围——检测与执行同源,不双写。 */
+export async function listManualEdits(repoPath) {
   try {
     const status = await createGit(repoPath).status()
-    return status
-      .split('\n')
-      .filter(Boolean)
-      .some((l) => {
-        const p = l.slice(3)
-        return p.startsWith('定稿') || p.startsWith('大纲')
-      })
+    const paths = []
+    for (const line of status.split('\n').filter(Boolean)) {
+      // porcelain 行「XY path」;重命名是「XY old -> new」,两侧都算手改
+      for (const p of line.slice(3).split(' -> ')) {
+        if (p.startsWith('定稿') || p.startsWith('大纲')) paths.push(p)
+      }
+    }
+    return paths
   } catch {
-    return false
+    return []
   }
 }
 
+/** 序2:定稿/大纲 有未登记手改(git 工作树有未提交改动) */
+export async function hasManualEdits(repoPath) {
+  return (await listManualEdits(repoPath)).length > 0
+}
+
 /**
- * 序3:工作区有未完成流程(细纲/材料/草稿/审稿/待定稿批次,spec 0.9 §10 续跑映射)。
+ * 序3 明细:工作区现存的断点工件 + 按 spec §10 续跑映射推断从哪继续(最深工件优先)。
  * 细纲计入——已确认的细纲是作者决策,不得被序 6 重新起草覆盖。
  */
-export async function hasUnfinishedWork(repoPath) {
+export async function unfinishedWorkDetail(repoPath) {
   const ws = path.join(repoPath, '工作区')
-  let files
+  let files = []
   try {
     files = await fs.readdir(ws)
   } catch {
-    return false
+    files = []
   }
-  if (
-    files.some(
-      (f) =>
-        f.startsWith('草稿') || f === '审稿.md' || f === '细纲.md' || f === '本章写作材料.md'
-    )
+  const 现存 = files.filter(
+    (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
+    if ((await fs.readdir(path.join(ws, '待定稿'))).length) 现存.push('待定稿/')
   } catch {
     // 无待定稿
   }
-  return false
+  const 从哪继续 = 现存.includes('待定稿/')
+    ? '待定稿批次续跑'
+    : 现存.includes('审稿.md')
+      ? '等作者裁决(接受/改完接受/打回)'
+      : 现存.some((f) => f.startsWith('草稿'))
+        ? '机检与两审'
+        : 现存.includes('本章写作材料.md')
+          ? '写稿'
+          : 现存.includes('细纲.md')
+            ? '出示细纲请作者过目,然后备料'
+            : ''
+  return { 现存, 从哪继续 }
+}
+
+/** 序3:工作区有未完成流程(细纲/材料/草稿/审稿/待定稿批次,spec 0.9 §10 续跑映射) */
+export async function hasUnfinishedWork(repoPath) {
+  return (await unfinishedWorkDetail(repoPath)).现存.length > 0
 }
 
 /** 序4 辅助:该卷的卷摘要已存在 = 卷复盘已完成(防复盘后重复触发) */

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

@@ -7,7 +7,7 @@ import { assembleBookStatus } from '../prep/book-status.js'
  * 产物回流由 M3 落盘(M4 不碰文件)。每个 DTO 标注 `期望产物` 告诉 M4 该产出什么。
  * @param {{repoPath, cache}} ctx
  * @param {number} 序
- * @param {object} base 路由已知信息(failures / 卷 / nextChapter)
+ * @param {object} base 路由已知信息(failures / manualEdits / 现存与从哪继续 / 卷 / nextChapter)
  */
 export async function buildDto(ctx, 序, base = {}) {
   switch (序) {
@@ -23,6 +23,20 @@ export async function buildDto(ctx, 序, base = {}) {
         缺: await whatsMissing(ctx),
         期望产物: '问答生成 book.yaml + 总纲 + 第一卷卷纲(由 M3 落盘 + 登记 books.jsonl)',
       }
+    case 2:
+      return {
+        state: 'relink-manual-edits',
+        变更文件: base.manualEdits || [],
+        补登命令: 'relink --message=<一句话说明>',
+        期望产物: '向作者出示变更清单问「补登吗」,确认后运行补登命令(fix(手改) 入档并刷新缓存);不补登则按作者指示处理',
+      }
+    case 3:
+      return {
+        state: 'resume',
+        工作区现存: base.现存 || [],
+        从哪继续: base.从哪继续 || '',
+        期望产物: '按「从哪继续」回到写章流程对应步骤(spec §10 续跑映射)',
+      }
     case 4: {
       const status = await assembleBookStatus(ctx)
       return {

+ 7 - 1
v7/src/state-machine/flows/goto-chapter.js

@@ -1,5 +1,6 @@
 import { createGit } from '../../finalize/git.js'
 import { checkGitHealth } from '../git-health.js'
+import { refreshCacheAfterSourceChange } from '../../cache/index.js'
 
 /**
  * 回到第 N 章(spec §9,git 回滚包装)。执行前展示影响范围 + 作者确认;
@@ -54,12 +55,17 @@ export async function gotoChapter(ctx, { chapterNum, confirm = false } = {}) {
   try {
     await git.createBackupRef(ref) // 备份当前 HEAD
     await git.resetHard(hash)
+    // 工作树已回旧 commit,缓存必须同步刷,否则 next 拿旧 maxChapter 起草错章号
+    const cacheRefresh = await refreshCacheAfterSourceChange(ctx)
+    const cacheNote =
+      cacheRefresh && !cacheRefresh.ok ? '(缓存刷新失败,已作废旧缓存,下次命令自动重建)' : ''
     return {
       ok: true,
       reverted: true,
       backupRef: `refs/${ref}`,
+      cacheRefresh,
       gitHealth,
-      message: `已回到第 ${chapterNum} 章。原状态已备份到 refs/${ref},如需找回:git reset --hard refs/${ref}`,
+      message: `已回到第 ${chapterNum} 章。原状态已备份到 refs/${ref},如需找回:git reset --hard refs/${ref}${cacheNote}`,
     }
   } catch (err) {
     return { ok: false, error: `回退失败:${err.message}`, gitHealth }

+ 4 - 1
v7/src/state-machine/flows/retcon.js

@@ -2,6 +2,7 @@ import path from 'node:path'
 import { createGit } from '../../finalize/git.js'
 import { EntityWriter } from '../../storage/adapters/EntityWriter.js'
 import { ThreadLedgerWriter } from '../../storage/adapters/ThreadLedgerWriter.js'
+import { refreshCacheAfterSourceChange } from '../../cache/index.js'
 
 /**
  * 吃书 retcon(spec §9):显式改定稿,commit `retcon(N): 原因`,设定/条目同步,留痕可查。
@@ -39,7 +40,9 @@ export async function retcon(ctx, { chapterNum, 原因, characterUpdates = [], t
     const rel = [...new Set(written)].map((f) => path.relative(repoPath, f))
     await git.add(rel)
     const commitHash = await git.commit(`retcon(${chapterNum}): ${原因}`)
-    return { ok: true, commitHash, message: `已吃书并留痕:retcon(${chapterNum}): ${原因}` }
+    // 角色/条目源已改,缓存同步刷,否则 threads/entities 陈旧
+    const cacheRefresh = await refreshCacheAfterSourceChange(ctx)
+    return { ok: true, commitHash, cacheRefresh, message: `已吃书并留痕:retcon(${chapterNum}): ${原因}` }
   } catch (err) {
     try {
       await git.restore(['定稿/', '大纲/'])

+ 7 - 5
v7/src/state-machine/index.js

@@ -30,14 +30,16 @@ export async function determineNextState(ctx) {
     return mk(1, 'create-book', true, '当前目录还没有书,进入建书引导。', gitHealth, await buildDto(ctx, 1, {}))
   }
 
-  // 序2 手改补登
-  if (await d.hasManualEdits(repoPath)) {
-    return mk(2, 'relink-manual-edits', false, '定稿/大纲 有未登记的手改,建议补登(fix)。', gitHealth, {})
+  // 序2 手改补登(检测=脚本;补登执行体也是脚本:relink 命令,作者确认后跑)
+  const manualEdits = await d.listManualEdits(repoPath)
+  if (manualEdits.length) {
+    return mk(2, 'relink-manual-edits', false, `定稿/大纲 有 ${manualEdits.length} 处未登记的手改,问作者「补登吗」,确认后运行 relink --message=<一句话说明> 入档。`, gitHealth, await buildDto(ctx, 2, { manualEdits }))
   }
 
   // 序3 断点续跑
-  if (await d.hasUnfinishedWork(repoPath)) {
-    return mk(3, 'resume', false, '工作区有未完成的流程,从中断处继续。', gitHealth, {})
+  const unfinished = await d.unfinishedWorkDetail(repoPath)
+  if (unfinished.现存.length) {
+    return mk(3, 'resume', false, `工作区有未完成的流程(${unfinished.现存.join('、')}),从「${unfinished.从哪继续}」继续。`, gitHealth, await buildDto(ctx, 3, unfinished))
   }
 
   // 序4/5/6 需章号信息

+ 19 - 4
v7/src/state-machine/persist.js

@@ -4,6 +4,7 @@ import { serializeYAML } from '../storage/serializers/yaml-dialect.js'
 import { parseFrontMatter } from '../storage/parsers/front-matter.js'
 import { writeAtomicBatch } from '../storage/atomic.js'
 import { createGit } from '../finalize/git.js'
+import { refreshCacheAfterSourceChange } from '../cache/index.js'
 
 /**
  * AI 态产物回流落盘(M3 落盘,AI 不碰文件)。AI 提交结构化 DTO,本层映射到路径写出。
@@ -71,14 +72,18 @@ export async function persistCreateBook(ctx, { book, 总纲, 卷纲 }) {
     // 建书产物随手提交(一次性 init 前缀):否则 next 立刻误触序2 手改检测;身份未配则局部兜底
     await git.ensureIdentity()
     await git.add(written)
-    await git.commit(`init: 建书《${book?.书名 || '未命名'}》`)
+    if (await git.hasStagedChanges()) {
+      await git.commit(`init: 建书《${book?.书名 || '未命名'}》`)
+    }
     return { ok: true, written, error: '' }
   } catch (err) {
     return { ok: false, written: [], error: `建书落盘失败:${err.message}` }
   }
 }
 
-/** 序4 卷复盘 → 定稿/摘要/卷摘要/第NN卷.md + 大纲/卷纲/第{卷号+1}卷.md(+ 可选伏笔条目) */
+/** 序4 卷复盘 → 定稿/摘要/卷摘要/第NN卷.md + 大纲/卷纲/第{卷号+1}卷.md(+ 可选伏笔条目)。
+ * 产物随手 commit(`vol(NN): 复盘与下卷规划`,spec §9)——与建书同理,不 commit 会让 next 误触序2 手改检测;
+ * 新伏笔条目改了源,同步刷缓存,否则 list-threads/备料/两审要等下次定稿才看得见。 */
 export async function persistVolumeReview(ctx, { 卷号, 卷摘要, 下卷卷纲, 伏笔条目 = [] }) {
   try {
     const files = []
@@ -93,7 +98,14 @@ export async function persistVolumeReview(ctx, { 卷号, 卷摘要, 下卷卷纲
       files.push({ path: path.join('大纲', '伏笔', `${e.id}.md`), content: body })
     }
     const written = await writeAtomicBatch(ctx.repoPath, files)
-    return { ok: true, written, error: '' }
+    const git = createGit(ctx.repoPath)
+    await git.ensureIdentity()
+    await git.add(written)
+    if (await git.hasStagedChanges()) {
+      await git.commit(`vol(${nn}): 复盘与下卷规划`)
+    }
+    const cacheRefresh = await refreshCacheAfterSourceChange(ctx)
+    return { ok: true, written, cacheRefresh, error: '' }
   } catch (err) {
     return { ok: false, written: [], error: `卷复盘落盘失败:${err.message}` }
   }
@@ -118,7 +130,10 @@ export async function persistRepair(ctx, { repairs }, { allowedFiles = [] } = {}
       ctx.repoPath,
       repairs.map((r) => ({ path: r.file, content: r.content }))
     )
-    return { ok: true, written, error: '' }
+    // 修复件此前因解析失败不在缓存,写完必须自刷,否则报表/备料继续缺数据。
+    // 修复本身不 commit:入档走序2 手改补登(relink),与 spec §9 的手改通道一致。
+    const cacheRefresh = await refreshCacheAfterSourceChange(ctx)
+    return { ok: true, written, cacheRefresh, error: '' }
   } catch (err) {
     return { ok: false, written: [], error: `修复落盘失败:${err.message}` }
   }

+ 23 - 8
v7/src/util/json-input.js

@@ -3,20 +3,35 @@ import path from 'node:path'
 
 /**
  * F1 通道的 JSON 输入一律走文件(--file/--payload,Windows 中文管道编码雷区,不走 stdin)。
- * 相对路径相对 ctx.repoPath(书仓库),无书时相对 ctx.workdir。
+ * 相对路径两处都找:先按启动目录(宿主常把临时 JSON 写在工作目录),再按书仓库
+ * (SKILL 约定临时 JSON 放 工作区/)。都没有则把找过的路径如实列出。
  * @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}` }
+  const candidates = []
+  if (path.isAbsolute(value)) {
+    candidates.push(value)
+  } else {
+    for (const base of [process.cwd(), ctx.repoPath || ctx.workdir]) {
+      if (!base) continue
+      const full = path.resolve(base, value)
+      if (!candidates.includes(full)) candidates.push(full)
+    }
+  }
+  let raw = null
+  for (const full of candidates) {
+    try {
+      raw = await fs.readFile(full, 'utf8')
+      break
+    } catch (err) {
+      if (err.code !== 'ENOENT') return { ok: false, error: `读不到 ${full}:${err.message}` }
+    }
+  }
+  if (raw == null) {
+    return { ok: false, error: `读不到 ${value}(找过:${candidates.join('、')})` }
   }
   try {
     const data = JSON.parse(raw)

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

@@ -152,6 +152,18 @@ test('persist-book:工作目录模式建书+指路 AGENTS.md+登记置当前;
     const badDir = await persistBook([], { file: p, dir: '../逃逸' }, ctx)
     assert.equal(badDir.ok, false)
     assert.ok(badDir.error.includes('不合法'))
+
+    // 书名含 Windows 非法字符 → 前置拦截并指路 --dir(P2-4)
+    const badName = await jsonFile(os.tmpdir(), `wnw-pb-bad-${process.pid}.json`, {
+      book: { spec_version: '7.0', 书名: '冒险:开始' },
+      总纲: '# 总纲\nx',
+      卷纲: '# 第1卷\ny',
+    })
+    const rBad = await persistBook([], { file: badName }, ctx)
+    assert.equal(rBad.ok, false)
+    assert.ok(rBad.error.includes('--dir'), '报错应指路 --dir 而不是 mkdir 深处报天书')
+    const rDir = await persistBook([], { file: badName, dir: '冒险开始' }, ctx)
+    assert.equal(rDir.ok, true, rDir.error)
   } finally {
     await fs.rm(workdir, { recursive: true, force: true })
   }
@@ -192,6 +204,10 @@ test('review-input:落 工作区/审稿输入.json(含草稿全文与章号
 
     const gone = await reviewInput(['1'], { draft: '工作区/没有.md' }, ctx)
     assert.equal(gone.ok, false)
+
+    // --draft 传绝对路径也要能读(P2-2:resolve 而非 join)
+    const abs = await reviewInput(['1'], { draft: path.join(root, '工作区', '草稿-A.md') }, ctx)
+    assert.equal(abs.ok, true, abs.error)
   } finally {
     await cleanup()
   }
@@ -239,6 +255,14 @@ test('save-review:两审 JSON 入库落审稿单;schema 不过人话报错', a
     const noDraft = await saveReview(['1'], { file: good, draft: '工作区/无.md' }, ctx)
     assert.equal(noDraft.ok, false)
     assert.ok(noDraft.error.includes('草稿'))
+
+    // --draft 传绝对路径也要能读(P2-2:resolve 而非 join)
+    const absDraft = await saveReview(
+      ['1'],
+      { file: good, draft: path.join(root, '工作区', '草稿-A.md') },
+      ctx
+    )
+    assert.equal(absDraft.ok, true, absDraft.error)
   } finally {
     await cleanup()
   }

+ 70 - 0
v7/test/commands/json-input.test.js

@@ -0,0 +1,70 @@
+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 { readJsonInput } from '../../src/util/json-input.js'
+
+/**
+ * P2-1:相对路径两处都找(先启动目录再书仓库),找不到把两处路径如实列出。
+ * 宿主在启动目录写临时 JSON 传相对名,此前按 repoPath 解析必 ENOENT。
+ */
+
+async function tmpDir(prefix) {
+  return fs.mkdtemp(path.join(os.tmpdir(), prefix))
+}
+
+test('json-input:绝对路径直接读', async () => {
+  const dir = await tmpDir('wnw-ji-')
+  try {
+    const p = path.join(dir, 'a.json')
+    await fs.writeFile(p, '{"x":1}', 'utf8')
+    const r = await readJsonInput({ repoPath: dir }, p, 'file')
+    assert.equal(r.ok, true)
+    assert.equal(r.data.x, 1)
+  } finally {
+    await fs.rm(dir, { recursive: true, force: true })
+  }
+})
+
+test('json-input:相对路径按启动目录找得到(宿主临时 JSON 常写在这)', async () => {
+  const cwdDir = await tmpDir('wnw-ji-cwd-')
+  const repoDir = await tmpDir('wnw-ji-repo-')
+  const oldCwd = process.cwd()
+  try {
+    await fs.writeFile(path.join(cwdDir, 'payload.json'), '{"来源":"启动目录"}', 'utf8')
+    process.chdir(cwdDir)
+    const r = await readJsonInput({ repoPath: repoDir }, 'payload.json', 'file')
+    assert.equal(r.ok, true, r.error)
+    assert.equal(r.data.来源, '启动目录')
+  } finally {
+    process.chdir(oldCwd)
+    await fs.rm(cwdDir, { recursive: true, force: true })
+    await fs.rm(repoDir, { recursive: true, force: true })
+  }
+})
+
+test('json-input:启动目录没有 → 回落书仓库解析(SKILL 约定放 工作区/)', async () => {
+  const repoDir = await tmpDir('wnw-ji-repo2-')
+  try {
+    await fs.mkdir(path.join(repoDir, '工作区'), { recursive: true })
+    await fs.writeFile(path.join(repoDir, '工作区', 'p.json'), '{"来源":"书仓库"}', 'utf8')
+    const r = await readJsonInput({ repoPath: repoDir }, path.join('工作区', 'p.json'), 'file')
+    assert.equal(r.ok, true, r.error)
+    assert.equal(r.data.来源, '书仓库')
+  } finally {
+    await fs.rm(repoDir, { recursive: true, force: true })
+  }
+})
+
+test('json-input:两处都没有 → 报错列出找过的路径', async () => {
+  const repoDir = await tmpDir('wnw-ji-repo3-')
+  try {
+    const r = await readJsonInput({ repoPath: repoDir }, '不存在.json', 'file')
+    assert.equal(r.ok, false)
+    assert.ok(r.error.includes('找过'), '报错应列出找过的路径')
+    assert.ok(r.error.includes(repoDir), '报错应包含书仓库侧的候选路径')
+  } finally {
+    await fs.rm(repoDir, { recursive: true, force: true })
+  }
+})

+ 22 - 0
v7/test/installer/install.test.js

@@ -182,3 +182,25 @@ test('update:既装宿主无需重新探测也继续跟新(manifest 记忆
     await cleanup()
   }
 })
+
+test('update:只更新部分宿主时其他宿主清单记录保留,手改不被下轮静默覆盖(P2-3)', async () => {
+  const { root, ctx, cleanup } = await tmpWorkdir()
+  try {
+    await installWorkdir(ctx, { hostsOverride: 'codex', ...NO_ENV })
+    // 只更 claude-code:.codex 的记录不能从清单里丢
+    await installWorkdir(ctx, { hostsOverride: 'claude-code', ...NO_ENV })
+    const m = await readManifest(root)
+    const codexKeys = Object.keys(m.files).filter((k) => k.startsWith('.codex'))
+    assert.ok(codexKeys.length > 0, '本轮未涉及的 .codex 记录应保留在清单里')
+
+    // 手改一个 .codex 文件后全量 update:应识别为手改跳过,而不是判 new 静默覆盖
+    const modified = codexKeys[0]
+    await fs.writeFile(path.join(root, modified), '用户手改内容', 'utf8')
+    const r = await installWorkdir(ctx, { hostsOverride: 'codex,claude-code', ...NO_ENV })
+    assert.equal(r.ok, true)
+    assert.ok(r.skipped.includes(modified), `${modified} 应被判手改跳过(实际 skipped:${r.skipped.join('、')})`)
+    assert.equal(await read(root, modified), '用户手改内容', '手改内容不得被覆盖')
+  } finally {
+    await cleanup()
+  }
+})

+ 6 - 3
v7/test/state-machine/persist.test.js

@@ -11,6 +11,7 @@ import {
   persistVolumeReview,
   persistDraftOutline,
 } from '../../src/state-machine/persist.js'
+import { makeGitBook } from './_helper.js'
 
 async function tmpRepo() {
   const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-persist-'))
@@ -73,13 +74,15 @@ test('persistCreateBook(序1)→ 写 book.yaml + 总纲 + 第一卷卷纲',
   } finally { await cleanup() }
 })
 
-test('persistVolumeReview(序4)→ 写卷摘要 + 下卷卷纲', async () => {
-  const { ctx, root, cleanup } = await tmpRepo()
+test('persistVolumeReview(序4)→ 写卷摘要 + 下卷卷纲 + vol commit(P1-1)', async () => {
+  const { ctx, root, git, cleanup } = await makeGitBook({ 'book.yaml': 'spec_version: "7.0"\n书名: 测\n' })
   try {
     const r = await persistVolumeReview(ctx, { 卷号: 1, 卷摘要: '第一卷收束。', 下卷卷纲: '# 第2卷\n新地图。' })
-    assert.equal(r.ok, true)
+    assert.equal(r.ok, true, r.error)
     assert.match(await read(root, '定稿/摘要/卷摘要/第01卷.md'), /收束/)
     assert.match(await read(root, '大纲/卷纲/第02卷.md'), /新地图/)
+    const { stdout } = await git(['log', '-1', '--format=%s'])
+    assert.match(stdout.trim(), /^vol\(01\): /, '复盘产物应随手 commit(否则 next 误触序2)')
   } finally { await cleanup() }
 })
 

+ 178 - 0
v7/test/state-machine/relay.test.js

@@ -0,0 +1,178 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { makeGitBook, chapter } from './_helper.js'
+import { determineNextState } from '../../src/state-machine/index.js'
+import { persistVolumeReview, persistRepair } from '../../src/state-machine/persist.js'
+import { gotoChapter } from '../../src/state-machine/flows/goto-chapter.js'
+import { retcon } from '../../src/state-machine/flows/retcon.js'
+import { run as relinkRun } from '../../src/commands/relink.js'
+
+/**
+ * 流程间接力测试(07-03 review 测试盲区):每个流程自身绿不够,
+ * 流程走完之后 next 判定还得对——本文件专测「流程完 → next」的交接。
+ */
+
+const BOOK = 'spec_version: "7.0"\n书名: 测\n'
+
+const volEndChapter = (n) =>
+  `---\n章号: ${n}\n标题: 第${n}章\n卷: 1\n字数: 100\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 高潮\n收卷: 是\n---\n大战落幕。`
+
+test('接力(P1-1/P1-2):卷复盘完 → 产物已入档、新伏笔进缓存、next 直进序6', async () => {
+  const { ctx, git, cleanup } = await makeGitBook(
+    { 'book.yaml': BOOK },
+    { commits: [{ message: 'ch(1): 收卷', files: { '定稿/正文/0001-收.md': volEndChapter(1) } }] }
+  )
+  try {
+    const before = await determineNextState(ctx)
+    assert.equal(before.序, 4, '收卷章 + 无卷摘要 → 应触发卷复盘')
+
+    const r = await persistVolumeReview(ctx, {
+      卷号: 1,
+      卷摘要: '第一卷收束。',
+      下卷卷纲: '# 第2卷\n新地图。',
+      伏笔条目: [
+        {
+          id: '伏笔-900',
+          frontMatter: { 强度: '中', 状态: '进行', 开启章: 1, 预计收尾: '第2卷', 最后推进章: 1 },
+          body: '## 描述\n新卷暗线。\n\n## 履历\n- 第1章:埋下',
+        },
+      ],
+    })
+    assert.equal(r.ok, true, r.error)
+
+    const { stdout } = await git(['log', '-1', '--format=%s'])
+    assert.match(stdout.trim(), /^vol\(01\): /)
+
+    // P1-2:新伏笔不等下次定稿,立即在缓存可见
+    const rows = await ctx.cache.query("SELECT id FROM threads WHERE id = '伏笔-900'")
+    assert.equal(rows.length, 1, '复盘产出的伏笔条目应立即进缓存')
+
+    // 接力终点:next 不误触序2(复盘产物已入档),直进序6 起草第2章
+    const after = await determineNextState(ctx)
+    assert.equal(after.序, 6, `复盘完 next 应进序6,实际序${after.序}:${after.message}`)
+    assert.equal(after.dto.nextChapter, 2)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('接力(P1-2):goto 回退完 → 缓存同步刷,next 起草的是正确章号', async () => {
+  const { ctx, cleanup } = await makeGitBook(
+    { 'book.yaml': BOOK },
+    {
+      commits: [
+        { message: 'ch(1): 起', files: { '定稿/正文/0001-起.md': chapter(1) } },
+        { message: 'ch(2): 承', files: { '定稿/正文/0002-承.md': chapter(2) } },
+        { message: 'ch(3): 转', files: { '定稿/正文/0003-转.md': chapter(3) } },
+      ],
+    }
+  )
+  try {
+    const before = await determineNextState(ctx)
+    assert.equal(before.dto.nextChapter, 4)
+
+    const r = await gotoChapter(ctx, { chapterNum: 1, confirm: true })
+    assert.equal(r.ok, true, r.error)
+    assert.equal(r.cacheRefresh?.ok, true, '回退后应同步刷新缓存')
+
+    const after = await determineNextState(ctx)
+    assert.equal(after.序, 6)
+    assert.equal(after.dto.nextChapter, 2, '回到第1章后应起草第2章(缓存不刷会报第4章)')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('接力(P1-2/P1-3):修复完缓存即时可见,序2 给清单,relink 补登后 next 进正事', async () => {
+  const { ctx, git, cleanup } = await makeGitBook({
+    'book.yaml': BOOK,
+    '定稿/正文/0001-起.md': '---\n坏: yaml: :\n---\n正文',
+  })
+  try {
+    const s0 = await determineNextState(ctx)
+    assert.equal(s0.序, 0, '解析失败应先进修复确认')
+
+    const target = '定稿/正文/0001-起.md'
+    const r = await persistRepair(
+      ctx,
+      { repairs: [{ file: target, content: chapter(1) }] },
+      { allowedFiles: [target] }
+    )
+    assert.equal(r.ok, true, r.error)
+
+    // P1-2:修复件不等下次定稿,立即进缓存
+    const rows = await ctx.cache.query('SELECT chapter_num FROM chapters')
+    assert.equal(rows.length, 1, '修复件应立即进缓存')
+
+    // 序2:修复件未入档 → 手改补登,dto 带变更清单(P1-3)
+    const s2 = await determineNextState(ctx)
+    assert.equal(s2.序, 2)
+    assert.deepEqual(s2.dto.变更文件, [target])
+    assert.ok(s2.message.includes('relink'), '序2 message 应指路 relink 命令')
+
+    // relink:补登通道(此前是死胡同——检测得到、没命令可执行)
+    const rl = await relinkRun([], { message: '修复解析失败的第1章' }, ctx)
+    assert.equal(rl.ok, true, rl.error)
+    const { stdout } = await git(['log', '-1', '--format=%s'])
+    assert.match(stdout.trim(), /^fix\(手改\): 修复解析失败的第1章$/)
+
+    // 接力终点:next 进正事
+    const s6 = await determineNextState(ctx)
+    assert.equal(s6.序, 6, `补登完 next 应进序6,实际序${s6.序}:${s6.message}`)
+    assert.equal(s6.dto.nextChapter, 2)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('接力(P1-2):retcon 完 → threads 缓存即时更新', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': BOOK,
+    '大纲/伏笔/伏笔-001-暗线.md':
+      '---\n强度: 高\n状态: 进行\n开启章: 1\n预计收尾: 第2卷\n最后推进章: 1\n---\n## 描述\n暗线。\n\n## 履历\n- 第1章:埋下',
+  })
+  try {
+    const r = await retcon(ctx, {
+      chapterNum: 1,
+      原因: '这条线提前收掉',
+      threadUpdates: [{ id: '伏笔-001', updates: { 状态: '回收' } }],
+    })
+    assert.equal(r.ok, true, r.error)
+    const rows = await ctx.cache.query("SELECT status FROM threads WHERE id = '伏笔-001'")
+    assert.equal(rows[0]?.status, '回收', '吃书后缓存应立即反映条目新状态')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('relink:缺 --message 报错;无手改时明说无需补登', async () => {
+  const { ctx, cleanup } = await makeGitBook({ 'book.yaml': BOOK })
+  try {
+    const noMsg = await relinkRun([], {}, ctx)
+    assert.equal(noMsg.ok, false)
+    assert.ok(noMsg.error.includes('--message'))
+
+    const clean = await relinkRun([], { message: '没改什么' }, ctx)
+    assert.equal(clean.ok, true)
+    assert.ok(clean.output.includes('无需补登'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序3 resume:dto 带工作区现存与从哪继续(P2-5)', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': BOOK,
+    '工作区/细纲.md': '## 本章要写到的事\nx',
+    '工作区/草稿-A.md': '草稿',
+  })
+  try {
+    const s = await determineNextState(ctx)
+    assert.equal(s.序, 3)
+    assert.ok(s.dto.工作区现存.includes('细纲.md'))
+    assert.ok(s.dto.工作区现存.includes('草稿-A.md'))
+    assert.equal(s.dto.从哪继续, '机检与两审', '最深工件是草稿 → 从机检/两审继续')
+  } finally {
+    await cleanup()
+  }
+})