فهرست منبع

feat(v7): M7 P3——migrate 物化编排、命令与迁移指引

- migrate/index.js:工作目录内临时目录建仓(写产物→git 单 init commit→缓存重建即格式校验)
  → 同盘 rename 落位 → books.jsonl 登记设当前书;失败删整树零残留,源 v6 全程只读
- .migrate-tmp- 残留幂等清扫;目标目录已存在人话拒绝(--dir 另起名)
- migrate 命令(scope: workdir)+ bin --help;SKILL 例外流程加 export/migrate 两行,壳重渲染 drift 绿
- docs/migration-guide.md:卸载市场版→npx init→migrate→校对清单
- e2e:AC2 两形态端到端(next 序 6 接力、删缓存重建一致)/AC3 十四类文本不丢字/
  AC4 注入失败回退+源指纹不变/AC5 报告三节;全量 429 绿
lingfengQAQ 1 روز پیش
والد
کامیت
e14545a

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

@@ -66,6 +66,7 @@ if (!command || command === '--help') {
   console.log('  list-books                               书单(登记缺失自动扫描重建)')
   console.log('  switch-book <书名>                       换书:改「当前」标记')
   console.log('  session-context                          会话上下文注入文本(hook 与入口同源)')
+  console.log('  migrate <v6项目路径> [--dir=<目录名>]     v6 书项目一次性迁移成 v7 书仓库(源只读,报告落工作区)')
   console.log('')
   console.log('状态机 / 例外流程(M3):')
   console.log('  next [--json]                           继续:状态机判定下一步(--json 出完整 DTO)')

+ 53 - 0
v7/docs/migration-guide.md

@@ -0,0 +1,53 @@
+# v6 → v7 迁移指引
+
+给用过 v6(插件市场版)的作者:v7 换了安装方式和数据格式,老书用一条命令迁过来,原项目不动。
+
+## 你会得到什么
+
+- 老书变成一个 v7 书仓库(定稿 / 大纲 / 文风 / 工作区 四个中文目录),正文一个字不丢。
+- v6 的伏笔逐条建档、角色进名册和角色卡、时间线和章摘要归位。
+- 一份人话迁移报告(`工作区/迁移报告.md`):迁了什么、哪些要校对、哪些如实没迁。
+- 原 v6 项目**只读未动**——迁移失败自动回退,不满意删掉新目录即可重来。
+
+## 操作步骤
+
+1. **卸载市场版插件**(v7 不再走插件市场):在原 AI 工具里移除 webnovel-writer 插件;老项目文件夹保留。
+2. **装 v7 工作目录**:挑一个存书的文件夹(以后所有书都住这里),在里面运行:
+
+   ```
+   npx webnovel-writer init
+   ```
+
+3. **迁移**:还在这个工作目录里,运行(把路径换成你的 v6 书项目根目录,就是含 `正文/` 的那个文件夹):
+
+   ```
+   webnovel-writer migrate <v6项目路径>
+   ```
+
+   想指定新目录名(比如同名书已存在)加 `--dir=<目录名>`。
+4. **校对**:打开新书目录下 `工作区/迁移报告.md`,按「待校对」一节从上往下过:
+   - `book.yaml` 的书名、题材对不对;
+   - `大纲/伏笔/` 里标了「迁移待补」的收尾计划补一句;
+   - 各处「迁移待校对-」开头的文件(记忆清单、文风候选、实体变更史)——有用的按提示归位,没用的直接删。
+5. **继续写作**:对 AI 说「继续」,就接上正常写章流程了。
+
+## 迁移了什么、没迁什么
+
+| v6 | 去处 |
+|----|------|
+| 正文(三种历史命名都认) | `定稿/正文/`(补章档案,章定位标「迁移」) |
+| 章摘要 | `定稿/摘要/章摘要/` |
+| 伏笔 | `大纲/伏笔/` 逐条一文件 |
+| 实体/别名(含 5.4+ 存数据库的) | 名册 + 角色卡 |
+| 时间线 | `定稿/设定/时间线/` |
+| 总纲/详细大纲/剧情线 | `大纲/总纲.md` + `大纲/卷纲/` |
+| 设定集 | `定稿/设定/` 原样 |
+| 记忆/文风套路 | 「迁移待校对-」文件,人工过一遍再入 |
+| 追读力债务台账、向量库等派生数据 | 不迁(报告里如实列出) |
+
+## 常见问题
+
+- **提示"不像 v6 书项目"**:路径要指到书项目根目录(含 `正文/` 或 `.webnovel/`),不是工作目录也不是插件目录。
+- **提示目录已存在**:加 `--dir=<新名字>`。
+- **state.json 坏了/丢了**:照样迁正文、大纲、设定集,报告会说明运行态数据没读到。
+- **迁完想重来**:删掉工作目录里的新书目录,重新跑 `migrate`——v6 原项目始终没动。

+ 2 - 0
v7/skills/webnovel-writer/SKILL.md

@@ -53,6 +53,8 @@ SessionStart 已注入「当前在写哪本 / 共几本 / 全书近况入口」
 - 回到第N章:`{{cmd}} goto-chapter <章号>`,先备份再回滚。
 - 影响分析:`{{cmd}} impact <关键词>`。
 - 吃书:按状态机指引改设定与条目,retcon commit。
+- 导出发布:`{{cmd}} export <章号>`(或 `--range=起-止` / `--all`),纯正文落 `工作区/导出/`,作者直接粘贴平台。
+- v6 迁移:作者说「迁移我的书」→ `{{cmd}} migrate <v6项目路径>`(源只读,失败自动回退);完成后读 `工作区/迁移报告.md` 向作者报要点,按「待校对」清单引导过一遍。
 
 ## 铁律
 - 事实变更只经定稿流程入 git。

+ 15 - 0
v7/src/commands/migrate.js

@@ -0,0 +1,15 @@
+import { migrateV6 } from '../migrate/index.js'
+
+export const scope = 'workdir'
+
+/**
+ * migrate <v6项目路径> [--dir=<目录名>]
+ * v6 书项目一次性迁移成 v7 书仓库(源只读;报告落 工作区/迁移报告.md)。
+ */
+export async function run(args, options, ctx) {
+  const v6Path = args[0]
+  if (!v6Path) {
+    return { ok: false, error: '用法:migrate <v6项目路径> [--dir=<目录名>]。把路径指向 v6 书项目根目录(含 正文/ 或 .webnovel/)。' }
+  }
+  return migrateV6(ctx, v6Path, { dir: options.dir })
+}

+ 157 - 0
v7/src/migrate/index.js

@@ -0,0 +1,157 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { readV6Project } from './read-v6.js'
+import { transformV6 } from './transform.js'
+import { createGit } from '../finalize/git.js'
+import { CacheManager } from '../cache/index.js'
+import { bookAgentsMd } from '../state-machine/persist.js'
+import { registerBook, setCurrentBook } from '../session/index.js'
+import { sanitizeFileName } from '../util/filename.js'
+
+const TMP_PREFIX = '.migrate-tmp-'
+
+/**
+ * v6 → v7 一次性迁移(design §3.3):源只读;临时目录建仓(写文件 → git 初始
+ * commit → 缓存重建验证格式自洽)→ 同盘 rename 落位 → books.jsonl 登记。
+ * 任一步失败删临时目录整树,工作目录零残留——"整体回退"即"没发生"。
+ * @param {{workdir: string}} ctx
+ * @param {string} v6PathArg v6 项目根(相对路径相对 cwd)
+ * @param {{dir?: string, _faultBeforeRename?: boolean}} opts _faultBeforeRename 仅测试注入用
+ */
+export async function migrateV6(ctx, v6PathArg, opts = {}) {
+  const v6Path = path.resolve(v6PathArg)
+  const read = await readV6Project(v6Path)
+  if (!read.ok) return { ok: false, error: read.error }
+  if (!read.facts.chapters.length && !read.facts.outlines.master) {
+    return { ok: false, error: `「${v6Path}」里没有正文也没有总纲,没有可迁移的内容。` }
+  }
+
+  const plan = transformV6(read.facts)
+  const dirName = sanitizeFileName(opts.dir || plan.bookName)
+  const target = path.join(ctx.workdir, dirName)
+  if (await exists(target)) {
+    return { ok: false, error: `工作目录里已有「${dirName}」,不覆盖既有目录。想另起名字用 --dir=<目录名>。` }
+  }
+
+  await sweepStaleTmp(ctx.workdir) // 上次进程中断的残留,幂等清扫
+
+  const tmp = path.join(ctx.workdir, `${TMP_PREFIX}${process.pid}`)
+  try {
+    // 1. 写全部映射产物 + 工程三件(AGENTS 指路 / .gitignore)
+    const written = []
+    for (const f of plan.files) {
+      const full = path.join(tmp, f.path)
+      await fs.mkdir(path.dirname(full), { recursive: true })
+      await fs.writeFile(full, f.content, 'utf8')
+      written.push(f.path)
+    }
+    for (const [rel, content] of [
+      ['AGENTS.md', bookAgentsMd(plan.bookName)],
+      ['.gitignore', '.cache/\n工作区/\n'],
+    ]) {
+      await fs.writeFile(path.join(tmp, rel), content, 'utf8')
+      written.push(rel)
+    }
+
+    // 2. git 初始 commit(v6 提交链压成一个 init,spec §12)
+    const git = createGit(tmp)
+    await git.init()
+    await git.setQuotepathFalse()
+    await git.ensureIdentity()
+    await git.add(written)
+    await git.commit(`init: 迁移自 v6《${plan.bookName}》`)
+
+    // 3. 缓存重建 = 格式自洽验证(重建器即参考实现,database 规范 §2.3)
+    const cache = new CacheManager(path.join(tmp, '.cache', 'index.db'))
+    try {
+      await cache.ensureReady(tmp)
+      const rebuilt = await cache.rebuildFromSource(tmp)
+      if (rebuilt && rebuilt.ok === false) {
+        throw new Error(`迁移产物没通过格式校验:${rebuilt.error}`)
+      }
+    } finally {
+      await cache.close()
+    }
+
+    // 4. 迁移报告(工作区不入 git,作者看完可删)
+    await fs.mkdir(path.join(tmp, '工作区'), { recursive: true })
+    await fs.writeFile(path.join(tmp, '工作区', '迁移报告.md'), renderReport(plan, v6Path, dirName), 'utf8')
+
+    if (opts._faultBeforeRename) throw new Error('注入故障(测试)')
+
+    // 5. 同盘 rename 落位
+    await fs.rename(tmp, target)
+  } catch (err) {
+    await fs.rm(tmp, { recursive: true, force: true })
+    return { ok: false, error: `迁移没有完成,工作目录已恢复原样(v6 项目未被改动)。原因:${err.message}` }
+  }
+
+  // 6. 登记书单并设为当前书(失败不回滚——书已落位,书单可扫描自愈)
+  let registryNote = ''
+  try {
+    await registerBook(ctx.workdir, { 书名: plan.bookName, 目录: dirName })
+    await setCurrentBook(ctx.workdir, plan.bookName)
+  } catch (err) {
+    registryNote = `\n书单登记没写上(${err.message}):跑一次 list-books 会自动扫描重建,不影响已迁移内容。`
+  }
+
+  const c = plan.report.counts
+  return {
+    ok: true,
+    target,
+    output:
+      `《${plan.bookName}》迁移完成:正文 ${c.章数} 章、伏笔 ${c.伏笔} 条、角色卡 ${c.角色卡} 张、` +
+      `章摘要 ${c.摘要} 篇、卷纲 ${c.卷纲} 卷,已设为当前书。\n` +
+      `迁移报告在 ${dirName}/工作区/迁移报告.md——先看「待校对」一节(${plan.report.待校对.length} 项),` +
+      `v6 项目原样未动,确认无误前别删。${registryNote}`,
+    error: '',
+  }
+}
+
+function renderReport(plan, v6Path, dirName) {
+  const { counts, 待校对, 丢弃, form } = plan.report
+  const 形态 = { inline: 'state.json 全量内联(老版)', sqlite: 'state.json 精简 + index.db(5.4+)', mixed: '内联与数据库混合' }[form] || form
+  const lines = [
+    `# 迁移报告:《${plan.bookName}》`,
+    '',
+    `迁移自:${v6Path}(数据形态:${形态};源目录只读未动)`,
+    `落位:工作目录/${dirName}`,
+    '',
+    '## 迁了什么',
+    '',
+    ...Object.entries(counts).map(([k, v]) => `- ${k}:${v}`),
+    '',
+    '## 待校对(建议按序过一遍)',
+    '',
+    ...(待校对.length ? 待校对.map((t, i) => `${i + 1}. ${t}`) : ['(没有需要校对的项)']),
+    '',
+    '## 如实丢弃(未迁移)',
+    '',
+    ...(丢弃.length ? 丢弃.map((t) => `- ${t}`) : ['(无)']),
+    '',
+    '> 校对完成后本报告可删;「迁移待校对-」开头的文件处理完也一并删掉。',
+    '',
+  ]
+  return lines.join('\n')
+}
+
+async function sweepStaleTmp(workdir) {
+  try {
+    for (const ent of await fs.readdir(workdir)) {
+      if (ent.startsWith(TMP_PREFIX)) {
+        await fs.rm(path.join(workdir, ent), { recursive: true, force: true })
+      }
+    }
+  } catch {
+    // 工作目录读不了让后续步骤报错,这里不拦
+  }
+}
+
+async function exists(p) {
+  try {
+    await fs.access(p)
+    return true
+  } catch {
+    return false
+  }
+}

+ 2 - 2
v7/src/state-machine/persist.js

@@ -39,8 +39,8 @@ export async function persistDraftOutline(ctx, { 细纲 }) {
   }
 }
 
-/** 书仓库指路 AGENTS.md(spec §2.1 建书时自动生成)。内容内嵌:运行时 vendored 副本不带 templates/ */
-function bookAgentsMd(书名) {
+/** 书仓库指路 AGENTS.md(spec §2.1 建书时自动生成;migrate 建仓复用)。内容内嵌:运行时 vendored 副本不带 templates/ */
+export function bookAgentsMd(书名) {
   return [
     '# webnovel-writer 书仓库',
     '',

+ 195 - 0
v7/test/migrate/e2e.test.js

@@ -0,0 +1,195 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import os from 'node:os'
+import { promises as fs } from 'node:fs'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { migrateV6 } from '../../src/migrate/index.js'
+import { run as migrateCmd } from '../../src/commands/migrate.js'
+import { CacheManager } from '../../src/cache/index.js'
+import { determineNextState } from '../../src/state-machine/index.js'
+import { loadBooks } from '../../src/session/index.js'
+import { tempV6, tempV6Sqlite, inlineFixture } from './_v6.js'
+
+const execFileAsync = promisify(execFile)
+
+async function tempWorkdir() {
+  const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-workdir-'))
+  await fs.mkdir(path.join(workdir, '.webnovel'), { recursive: true })
+  return { ctx: { workdir, packageRoot: null }, cleanup: () => fs.rm(workdir, { recursive: true, force: true }) }
+}
+
+/** 目录树指纹:相对路径 → 内容长度(源只读断言用)。 */
+async function treeFingerprint(root) {
+  const map = new Map()
+  async function walk(dir) {
+    for (const ent of await fs.readdir(dir, { withFileTypes: true })) {
+      const full = path.join(dir, ent.name)
+      if (ent.isDirectory()) await walk(full)
+      else map.set(path.relative(root, full), (await fs.readFile(full)).length)
+    }
+  }
+  await walk(root)
+  return map
+}
+
+async function readTree(root) {
+  const parts = []
+  async function walk(dir) {
+    for (const ent of await fs.readdir(dir, { withFileTypes: true })) {
+      if (ent.name === '.git' || ent.name === '.cache') continue
+      const full = path.join(dir, ent.name)
+      if (ent.isDirectory()) await walk(full)
+      else parts.push(await fs.readFile(full, 'utf8'))
+    }
+  }
+  await walk(root)
+  return parts.join('\n')
+}
+
+test('AC2 端到端(inline):迁移落位、git 单 commit、缓存可删重建、next 进正常写章流程', async () => {
+  const { ctx, cleanup } = await tempWorkdir()
+  const v6 = await tempV6(inlineFixture)
+  try {
+    const r = await migrateV6(ctx, v6.v6Path)
+    assert.equal(r.ok, true, r.error)
+    const repo = path.join(ctx.workdir, '剑碎虚空')
+
+    // 结构合规:正文/摘要/条目/名册/时间线/卷纲/工程件
+    for (const p of [
+      '定稿/正文/0001-残剑出鞘.md', '定稿/正文/0002-第2章.md', '定稿/正文/0003-剑灵初醒.md',
+      '定稿/摘要/章摘要/0001.md', '大纲/伏笔/伏笔-001-残剑剑柄内藏半张古.md',
+      '定稿/设定/名册.md', '定稿/设定/角色/陆沉.md', '定稿/设定/时间线/第01卷.md',
+      '大纲/总纲.md', '大纲/卷纲/第01卷.md', 'book.yaml', 'AGENTS.md', '.gitignore',
+      '工作区/迁移报告.md',
+    ]) {
+      await fs.access(path.join(repo, p))
+    }
+
+    // git:提交链压成单个 init commit,工作树净(工作区被 ignore)
+    const { stdout: cnt } = await execFileAsync('git', ['rev-list', '--count', 'HEAD'], { cwd: repo })
+    assert.equal(cnt.trim(), '1')
+    const { stdout: st } = await execFileAsync('git', ['status', '--porcelain'], { cwd: repo })
+    assert.equal(st.trim(), '')
+
+    // 删缓存全量重建一致(不变量 2)
+    await fs.rm(path.join(repo, '.cache'), { recursive: true, force: true })
+    const cache = new CacheManager(path.join(repo, '.cache', 'index.db'))
+    try {
+      await cache.ensureReady(repo)
+      const rows = await cache.query('SELECT COUNT(*) AS n FROM chapters', [])
+      assert.equal(rows[0].n, 3)
+      // AC7 口径顺检:迁移不产生指纹(体检才产)
+      assert.equal((await cache.query('SELECT COUNT(*) AS n FROM fingerprints', []))[0].n, 0)
+
+      // next 直接进正常流程:序 6 起草第 4 章
+      const next = await determineNextState({ repoPath: repo, cache, workdir: ctx.workdir })
+      assert.equal(next.序, 6, JSON.stringify(next))
+      assert.equal(next.dto.nextChapter, 4)
+    } finally {
+      await cache.close()
+    }
+
+    // books.jsonl 登记 + 当前书
+    const books = await loadBooks(ctx.workdir)
+    assert.ok(books.books.some((b) => b.书名 === '剑碎虚空'))
+  } finally {
+    await v6.cleanup()
+    await cleanup()
+  }
+})
+
+test('AC2 端到端(sqlite 形态)+ AC5 报告三节', async () => {
+  const { ctx, cleanup } = await tempWorkdir()
+  const v6 = await tempV6Sqlite()
+  try {
+    const r = await migrateV6(ctx, v6.v6Path)
+    assert.equal(r.ok, true, r.error)
+    const report = await fs.readFile(path.join(ctx.workdir, '潮汐之下', '工作区', '迁移报告.md'), 'utf8')
+    assert.match(report, /## 迁了什么/)
+    assert.match(report, /- 章数:2/)
+    assert.match(report, /## 待校对/)
+    assert.match(report, /## 如实丢弃/)
+    assert.match(report, /index\.db 实体已转正文件/)
+    assert.match(report, /数据形态:state\.json 精简 \+ index\.db/)
+  } finally {
+    await v6.cleanup()
+    await cleanup()
+  }
+})
+
+test('AC3 不丢字:v6 每类文本在 v7 产物或待校对区可寻回', async () => {
+  const { ctx, cleanup } = await tempWorkdir()
+  const v6 = await tempV6(inlineFixture)
+  try {
+    const r = await migrateV6(ctx, v6.v6Path)
+    assert.equal(r.ok, true, r.error)
+    const tree = await readTree(path.join(ctx.workdir, '剑碎虚空'))
+    for (const text of [
+      '晨雾未散,陆沉背着那柄锈迹斑斑的残剑走进演武场', // 正文(平坦带标题)
+      '藏经阁的木梯吱呀作响', // 正文(遗留无标题)
+      '淤塞多年的气海竟被撕开一道细缝', // 正文(卷内 3 位)
+      '陆沉携残剑入演武场受三长老盘问', // 章摘要
+      '残剑剑柄内藏半张古图', // 伏笔 content
+      '外门大比', // active_threads → 卷纲
+      '三长老着人盯梢陆沉,尚未收线', // scratchpad open_loops → 待校对
+      '陆家灭门夜唯一遗物', // scratchpad story_facts → 待校对
+      '战斗段落短句连用,收在动作定格', // patterns → 文风候选
+      '剑灵反哺', // state_changes → 实体变更史
+      '藏经阁初见,苏素予其残卷', // structured_relationships → 角色卡关系
+      '外门三千弟子,藏经阁七层', // 设定集
+      '陆沉集齐古图,于虚空裂隙斩落伪天道', // 总纲
+      '剑灵初醒,破至练气五层', // 时间线事件
+    ]) {
+      assert.ok(tree.includes(text), `丢字:找不到「${text}」`)
+    }
+  } finally {
+    await v6.cleanup()
+    await cleanup()
+  }
+})
+
+test('AC4 回退演练:中途失败工作目录零残留、源 v6 未被改动;残留临时目录会被清扫', async () => {
+  const { ctx, cleanup } = await tempWorkdir()
+  const v6 = await tempV6(inlineFixture)
+  try {
+    const before = await treeFingerprint(v6.v6Path)
+    // 预埋一个上次中断的残留临时目录
+    await fs.mkdir(path.join(ctx.workdir, '.migrate-tmp-99999', '定稿'), { recursive: true })
+
+    const r = await migrateV6(ctx, v6.v6Path, { _faultBeforeRename: true })
+    assert.equal(r.ok, false)
+    assert.match(r.error, /工作目录已恢复原样/)
+
+    const left = await fs.readdir(ctx.workdir)
+    assert.ok(!left.some((n) => n.startsWith('.migrate-tmp-')), `残留:${left}`)
+    assert.ok(!left.includes('剑碎虚空'))
+    assert.deepEqual(await treeFingerprint(v6.v6Path), before) // 源逐文件未动
+  } finally {
+    await v6.cleanup()
+    await cleanup()
+  }
+})
+
+test('目标目录已存在拒绝(--dir 另起名可走);命令壳用法提示', async () => {
+  const { ctx, cleanup } = await tempWorkdir()
+  const v6 = await tempV6(inlineFixture)
+  try {
+    await fs.mkdir(path.join(ctx.workdir, '剑碎虚空'))
+    const clash = await migrateV6(ctx, v6.v6Path)
+    assert.equal(clash.ok, false)
+    assert.match(clash.error, /已有「剑碎虚空」/)
+
+    const alt = await migrateCmd([v6.v6Path], { dir: '剑碎虚空-旧稿' }, ctx)
+    assert.equal(alt.ok, true, alt.error)
+    await fs.access(path.join(ctx.workdir, '剑碎虚空-旧稿', 'book.yaml'))
+
+    const noArg = await migrateCmd([], {}, ctx)
+    assert.equal(noArg.ok, false)
+    assert.match(noArg.error, /用法/)
+  } finally {
+    await v6.cleanup()
+    await cleanup()
+  }
+})