Просмотр исходного кода

feat(v7): M4 P1——AI 态产物落盘契约 + SessionStart 注入与书单自愈

- persist.js:序1建书(book.yaml+总纲+卷纲)/序4卷复盘(卷摘要+下卷卷纲+伏笔条目)/序6细纲/序0修复;
  序0安全网=只写失败清单内文件且修复内容须能解析(防 AI 任意写)
- session/index.js:readBooksRegistry(损坏行跳过)、scanRebuildBooks(扫 book.yaml 重建)、
  assembleSessionContext(缺登记自动重建);两入口同一函数→无 hook 等价
- 12 测试绿(全量 229 绿);hook/CLI 实际接线属 M5
lingfengQAQ 13 часов назад
Родитель
Сommit
3298fb02b0

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

@@ -33,10 +33,10 @@ P5 AC 复核 + CI 双平台;真模型 smoke 推迟文档
 
 ## P1 AI 态落盘契约 + SessionStart(R2/R3)
 
-- [ ] P1.1 `src/state-machine/persist.js`:`persistRepair/persistCreateBook/persistVolumeReview/persistDraftOutline`——吃 AI 结构化 DTO,只经 M2 Writer 落盘。先红:断言落盘文件内容 + 零路径泄漏给 AI。
-- [ ] P1.2 `src/session/index.js`:`readBooksRegistry`(损坏行跳过)、`scanRebuildBooks`(扫 book.yaml 重建)、`assembleSessionContext`(注入文本)。
-- [ ] P1.3 无 hook 等价:状态机入口调 `assembleSessionContext` 与 hook 注入逐字一致(测试断言)
-- [ ] P1.4 `test/state-machine/persist.test.js`、`test/session/`:各 AI 态落盘、books.jsonl 读/重建/缺当前书、等价路径
+- [x] P1.1 `src/state-machine/persist.js`:`persistRepair/persistCreateBook/persistVolumeReview/persistDraftOutline`——吃 AI 结构化 DTO 落盘。序0 安全网:只写失败清单内文件 + 修复内容须能解析。(book.yaml/总纲/卷纲/卷摘要无对应 Writer,用 serializeYAML/fs;伏笔条目用 serializeFrontMatter)
+- [x] P1.2 `src/session/index.js`:`readBooksRegistry`(损坏行跳过计数)、`scanRebuildBooks`(扫 book.yaml 重建,需作者选当前书)、`assembleSessionContext`(注入文本,缺登记自动重建)。
+- [x] P1.3 无 hook 等价:两入口调同一 `assembleSessionContext` → 注入逐字一致(测试断言)。实际 hook/CLI 接线属 M5 安装器
+- [x] P1.4 `test/state-machine/persist.test.js`(6)、`test/session/session.test.js`(6):各 AI 态落盘 + 安全网、books.jsonl 读/重建/等价。全量 229 绿
 
 **验证 P1**:`node --test test/state-machine/ test/session/` 全绿
 **提交 P1**:`feat(v7): M4 P1——AI 态产物落盘契约 + SessionStart 注入与书单自愈`

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

@@ -0,0 +1,79 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
+
+/**
+ * SessionStart 注入与书单自愈(story-repo-spec §2.0)。
+ * 有 hook 宿主(Claude Code)启动调本层;无 hook 宿主由状态机入口调同一函数,行为等价。
+ * 写侧(books.jsonl 登记/换书)属 M5;本层只读 + 扫描重建。
+ */
+
+/** 读 .webnovel/books.jsonl,逐行 JSON,损坏行跳过并计数 */
+export async function readBooksRegistry(workdir) {
+  const p = path.join(workdir, '.webnovel', 'books.jsonl')
+  let content
+  try {
+    content = await fs.readFile(p, 'utf8')
+  } catch {
+    return { ok: false, missing: true, books: [], corrupt: 0 }
+  }
+  const books = []
+  let corrupt = 0
+  for (const line of content.split('\n')) {
+    const t = line.trim()
+    if (!t) continue
+    try {
+      books.push(JSON.parse(t))
+    } catch {
+      corrupt++
+    }
+  }
+  return { ok: true, missing: false, books, corrupt }
+}
+
+/** 扫工作目录子目录,含 book.yaml 的重建书单(spec §0 可重建)。当前书标记缺失 → 需作者选一次 */
+export async function scanRebuildBooks(workdir) {
+  let entries
+  try {
+    entries = await fs.readdir(workdir, { withFileTypes: true })
+  } catch (err) {
+    return { ok: false, books: [], needsAuthorPick: false, error: err.message }
+  }
+  const books = []
+  for (const e of entries) {
+    if (!e.isDirectory() || e.name.startsWith('.')) continue
+    const cfg = await new BookConfigReader(path.join(workdir, e.name)).read()
+    if (cfg.ok) {
+      books.push({ 书名: cfg.data.书名 || e.name, 目录: e.name, 当前: false })
+    }
+  }
+  return { ok: true, books, needsAuthorPick: books.length > 0, error: '' }
+}
+
+/**
+ * 组装 SessionStart 注入文本(当前在写哪本/共几本/全书近况入口)。
+ * 登记缺失或为空 → 扫描重建。两个宿主入口调本函数 → 注入逐字一致。
+ */
+export async function assembleSessionContext(workdir) {
+  let reg = await readBooksRegistry(workdir)
+  let rebuilt = false
+  let needsAuthorPick = false
+  if (!reg.ok || reg.missing || reg.books.length === 0) {
+    const scan = await scanRebuildBooks(workdir)
+    reg = { books: scan.books }
+    rebuilt = true
+    needsAuthorPick = scan.needsAuthorPick
+  }
+  const current = reg.books.find((b) => b.当前) || null
+  const names = reg.books.map((b) => b.书名).join('、')
+  const text = [
+    current
+      ? `当前在写《${current.书名}》`
+      : reg.books.length
+        ? `尚未选择当前书(候选:${names})`
+        : '尚未选择当前书',
+    `共 ${reg.books.length} 本`,
+    current ? '继续写作直接说「继续」(将读当前书全书近况)' : '请选择要写哪本书',
+  ].join(';')
+  return { ok: true, text, books: reg.books, current, rebuilt, needsAuthorPick }
+}

+ 80 - 0
v7/src/state-machine/persist.js

@@ -0,0 +1,80 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { serializeYAML } from '../storage/serializers/yaml-dialect.js'
+import { parseFrontMatter } from '../storage/parsers/front-matter.js'
+
+/**
+ * AI 态产物回流落盘(M3 落盘,AI 不碰文件)。AI 提交结构化 DTO,本层映射到路径写出。
+ * 与 dto.js(读侧组装)对称。
+ */
+
+async function writeFile(repoPath, rel, content) {
+  const full = path.join(repoPath, rel)
+  await fs.mkdir(path.dirname(full), { recursive: true })
+  await fs.writeFile(full, content, 'utf8')
+  return rel
+}
+
+/** 序6 起草细纲 → 工作区/细纲.md */
+export async function persistDraftOutline(ctx, { 细纲 }) {
+  try {
+    const rel = await writeFile(ctx.repoPath, path.join('工作区', '细纲.md'), 细纲)
+    return { ok: true, written: [rel], error: '' }
+  } catch (err) {
+    return { ok: false, written: [], error: `落盘细纲失败:${err.message}` }
+  }
+}
+
+/** 序1 建书 → book.yaml + 大纲/总纲.md + 大纲/第01卷.md */
+export async function persistCreateBook(ctx, { book, 总纲, 卷纲 }) {
+  try {
+    const written = []
+    written.push(await writeFile(ctx.repoPath, 'book.yaml', serializeYAML(book)))
+    written.push(await writeFile(ctx.repoPath, path.join('大纲', '总纲.md'), 总纲))
+    written.push(await writeFile(ctx.repoPath, path.join('大纲', '第01卷.md'), 卷纲))
+    return { ok: true, written, error: '' }
+  } catch (err) {
+    return { ok: false, written: [], error: `建书落盘失败:${err.message}` }
+  }
+}
+
+/** 序4 卷复盘 → 定稿/摘要/卷摘要/NN.md + 大纲/第{卷号+1}卷.md(+ 可选伏笔条目) */
+export async function persistVolumeReview(ctx, { 卷号, 卷摘要, 下卷卷纲, 伏笔条目 = [] }) {
+  try {
+    const written = []
+    const nn = String(卷号).padStart(2, '0')
+    written.push(await writeFile(ctx.repoPath, path.join('定稿', '摘要', '卷摘要', `${nn}.md`), 卷摘要))
+    if (下卷卷纲) {
+      const next = String(卷号 + 1).padStart(2, '0')
+      written.push(await writeFile(ctx.repoPath, path.join('大纲', `第${next}卷.md`), 下卷卷纲))
+    }
+    for (const e of 伏笔条目) {
+      const body = `---\n${serializeYAML(e.frontMatter || {})}\n---\n${e.body || ''}`
+      written.push(await writeFile(ctx.repoPath, path.join('大纲', '伏笔', `${e.id}.md`), body))
+    }
+    return { ok: true, written, error: '' }
+  } catch (err) {
+    return { ok: false, written: [], error: `卷复盘落盘失败:${err.message}` }
+  }
+}
+
+/**
+ * 序0 修复确认 → 写回修复后的源文件。安全网:
+ * 只写在 allowedFiles(M3 检测到的失败清单)内的文件;修复内容必须能解析,否则不写。
+ */
+export async function persistRepair(ctx, { repairs }, { allowedFiles = [] } = {}) {
+  const written = []
+  for (const r of repairs) {
+    if (!allowedFiles.includes(r.file)) {
+      return { ok: false, written, error: `拒绝写入非失败清单文件:${r.file}` }
+    }
+    const parsed = parseFrontMatter(r.content)
+    if (!parsed.ok) {
+      return { ok: false, written, error: `修复内容仍解析失败(${r.file}):${parsed.error}` }
+    }
+  }
+  for (const r of repairs) {
+    written.push(await writeFile(ctx.repoPath, r.file, r.content))
+  }
+  return { ok: true, written, error: '' }
+}

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

@@ -0,0 +1,96 @@
+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 {
+  readBooksRegistry,
+  scanRebuildBooks,
+  assembleSessionContext,
+} from '../../src/session/index.js'
+
+async function tmpWorkdir() {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-wd-'))
+  return { root, cleanup: () => fs.rm(root, { recursive: true, force: true }) }
+}
+async function writeRegistry(root, lines) {
+  await fs.mkdir(path.join(root, '.webnovel'), { recursive: true })
+  await fs.writeFile(path.join(root, '.webnovel', 'books.jsonl'), lines.join('\n') + '\n', 'utf8')
+}
+async function makeBookDir(root, name) {
+  await fs.mkdir(path.join(root, name), { recursive: true })
+  await fs.writeFile(path.join(root, name, 'book.yaml'), `spec_version: "7.0"\n书名: ${name}\n`, 'utf8')
+}
+
+test('readBooksRegistry:解析合法行,损坏行跳过并计数', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await writeRegistry(root, [
+      JSON.stringify({ 书名: '剑起青云', 目录: '剑起青云', 当前: true }),
+      '{坏的 json',
+      JSON.stringify({ 书名: '星海', 目录: '星海', 当前: false }),
+    ])
+    const r = await readBooksRegistry(root)
+    assert.equal(r.ok, true)
+    assert.equal(r.books.length, 2)
+    assert.equal(r.corrupt, 1)
+  } finally { await cleanup() }
+})
+
+test('readBooksRegistry:缺文件 → missing=true 不抛', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    const r = await readBooksRegistry(root)
+    assert.equal(r.missing, true)
+    assert.equal(r.books.length, 0)
+  } finally { await cleanup() }
+})
+
+test('scanRebuildBooks:扫含 book.yaml 子目录重建,需作者选当前书', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await makeBookDir(root, '剑起青云')
+    await makeBookDir(root, '星海')
+    const r = await scanRebuildBooks(root)
+    assert.equal(r.ok, true)
+    assert.equal(r.books.length, 2)
+    assert.ok(r.books.some((b) => b.书名 === '剑起青云'))
+    assert.equal(r.needsAuthorPick, true)
+  } finally { await cleanup() }
+})
+
+test('assembleSessionContext:有登记 → 注入含当前书与本数', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await writeRegistry(root, [
+      JSON.stringify({ 书名: '剑起青云', 目录: '剑起青云', 当前: true }),
+      JSON.stringify({ 书名: '星海', 目录: '星海', 当前: false }),
+    ])
+    const r = await assembleSessionContext(root)
+    assert.equal(r.ok, true)
+    assert.match(r.text, /剑起青云/)
+    assert.match(r.text, /2 本/)
+    assert.equal(r.current.书名, '剑起青云')
+  } finally { await cleanup() }
+})
+
+test('assembleSessionContext:登记缺失 → 扫描重建并标记', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await makeBookDir(root, '剑起青云')
+    const r = await assembleSessionContext(root)
+    assert.equal(r.ok, true)
+    assert.equal(r.rebuilt, true)
+    assert.match(r.text, /剑起青云/)
+  } finally { await cleanup() }
+})
+
+test('无 hook 等价:hook 入口与状态机入口调同一函数 → 注入文本逐字一致', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await writeRegistry(root, [JSON.stringify({ 书名: '剑起青云', 目录: '剑起青云', 当前: true })])
+    const hookText = (await assembleSessionContext(root)).text // Claude Code SessionStart hook
+    const smText = (await assembleSessionContext(root)).text // 无 hook 宿主由状态机入口调
+    assert.equal(hookText, smText)
+  } finally { await cleanup() }
+})

+ 91 - 0
v7/test/state-machine/persist.test.js

@@ -0,0 +1,91 @@
+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 {
+  persistRepair,
+  persistCreateBook,
+  persistVolumeReview,
+  persistDraftOutline,
+} from '../../src/state-machine/persist.js'
+
+async function tmpRepo() {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-persist-'))
+  return { ctx: { repoPath: root }, root, cleanup: () => fs.rm(root, { recursive: true, force: true }) }
+}
+const read = (root, rel) => fs.readFile(path.join(root, rel), 'utf8')
+
+test('persistDraftOutline(序6)→ 写 工作区/细纲.md', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  try {
+    const r = await persistDraftOutline(ctx, { 细纲: '## 本章要写到的事\n林晚突破。\n' })
+    assert.equal(r.ok, true)
+    assert.match(await read(root, '工作区/细纲.md'), /林晚突破/)
+  } finally { await cleanup() }
+})
+
+test('persistCreateBook(序1)→ 写 book.yaml + 总纲 + 第一卷卷纲', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  try {
+    const r = await persistCreateBook(ctx, {
+      book: { spec_version: '7.0', 书名: '剑起青云', 题材: '仙侠' },
+      总纲: '# 总纲\n主角逆袭。',
+      卷纲: '# 第1卷\n入门。',
+    })
+    assert.equal(r.ok, true)
+    assert.match(await read(root, 'book.yaml'), /剑起青云/)
+    assert.match(await read(root, '大纲/总纲.md'), /逆袭/)
+    assert.match(await read(root, '大纲/第01卷.md'), /入门/)
+  } finally { await cleanup() }
+})
+
+test('persistVolumeReview(序4)→ 写卷摘要 + 下卷卷纲', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  try {
+    const r = await persistVolumeReview(ctx, { 卷号: 1, 卷摘要: '第一卷收束。', 下卷卷纲: '# 第2卷\n新地图。' })
+    assert.equal(r.ok, true)
+    assert.match(await read(root, '定稿/摘要/卷摘要/01.md'), /收束/)
+    assert.match(await read(root, '大纲/第02卷.md'), /新地图/)
+  } finally { await cleanup() }
+})
+
+test('persistRepair(序0)→ 仅写失败清单内的文件,内容须能解析', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  try {
+    const target = '定稿/正文/0001-起.md'
+    await fs.mkdir(path.join(root, '定稿/正文'), { recursive: true })
+    await fs.writeFile(path.join(root, target), '---\n坏: yaml: :\n---\n正文', 'utf8')
+    const good = '---\n章号: 1\n标题: 起\n---\n正文'
+    const r = await persistRepair(ctx, { repairs: [{ file: target, content: good }] }, { allowedFiles: [target] })
+    assert.equal(r.ok, true)
+    assert.deepEqual(r.written, [target])
+    assert.match(await read(root, target), /章号: 1/)
+  } finally { await cleanup() }
+})
+
+test('persistRepair:拒绝写不在失败清单内的文件(安全网,防 AI 任意写)', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  try {
+    const r = await persistRepair(
+      ctx,
+      { repairs: [{ file: '定稿/正文/9999-注入.md', content: '---\n章号: 9\n---\nx' }] },
+      { allowedFiles: ['定稿/正文/0001-起.md'] }
+    )
+    assert.equal(r.ok, false)
+    await assert.rejects(() => read(root, '定稿/正文/9999-注入.md'))
+  } finally { await cleanup() }
+})
+
+test('persistRepair:修复内容仍解析失败 → ok=false 不写', async () => {
+  const { ctx, root, cleanup } = await tmpRepo()
+  try {
+    const target = '定稿/正文/0001-起.md'
+    const r = await persistRepair(
+      ctx,
+      { repairs: [{ file: target, content: '---\n仍坏: : :\n---\nx' }] },
+      { allowedFiles: [target] }
+    )
+    assert.equal(r.ok, false)
+  } finally { await cleanup() }
+})