1
0
Эх сурвалжийг харах

feat(v7): M1 阶段 B——Storage Adapter 小端口(8 Reader + Writer 占位)

- Fixture 完整示例(sample-book:2 章/1 角色/1 伏笔/1 信息差/时间线/名册/book.yaml)
- ChapterReader:readFrontMatter/Body/Tail/Head/Range(缓存降级)
- ThreadLedgerReader:readBasicInfo/History/ClosurePlan/Description/listOverdue/listByType
- EntityReader:readCharacterFrontMatter/Full/resolveAlias/listCharacters
- TimelineReader:readCurrentVolume/VolumeRange/ByParticipant
- SecretReader:readBasicInfo/Content/listUnrevealed
- OutlineReader:readOutlineSection/VolumeOutline/listVolumes
- BookConfigReader:read() 返回 book.yaml 配置
- ChapterWriter/ThreadLedgerWriter:接口占位(抛 M2 实现错误)
- 测试:17 个用例全绿(无缓存路径、边界、筛选逻辑验证)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lingfengQAQ 1 өдөр өмнө
parent
commit
7defdbad2b

+ 28 - 0
v7/src/storage/adapters/BookConfigReader.js

@@ -0,0 +1,28 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseBookConfig } from '../parsers/book-config.js'
+
+/**
+ * BookConfigReader:读取 book.yaml。
+ */
+export class BookConfigReader {
+  constructor(repoPath) {
+    this.repoPath = repoPath
+  }
+
+  /**
+   * 读取 book.yaml 配置。
+   * @returns {Promise<{ok: boolean, data: object|null, error: string}>}
+   */
+  async read() {
+    const configPath = path.join(this.repoPath, 'book.yaml')
+
+    try {
+      const content = await fs.readFile(configPath, 'utf8')
+      const result = parseBookConfig(content)
+      return result
+    } catch (err) {
+      return { ok: false, data: null, error: 'book.yaml 不存在' }
+    }
+  }
+}

+ 162 - 0
v7/src/storage/adapters/ChapterReader.js

@@ -0,0 +1,162 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseFrontMatter } from '../parsers/front-matter.js'
+
+/**
+ * ChapterReader:读取章节 front matter、正文、范围。
+ */
+export class ChapterReader {
+  constructor(repoPath, cache = null) {
+    this.repoPath = repoPath
+    this.cache = cache // CacheManager 实例(可选)
+  }
+
+  /**
+   * 读取章节 front matter。
+   * @param {number} chapterNum - 章号
+   * @returns {Promise<{ok: boolean, data: object|null, error: string}>}
+   */
+  async readFrontMatter(chapterNum) {
+    // 策略:优先查缓存 chapters 表,缺失时读文件
+    if (this.cache) {
+      try {
+        const rows = await this.cache.query(
+          'SELECT * FROM chapters WHERE chapter_num = ?',
+          [chapterNum]
+        )
+        if (rows.length > 0) {
+          return { ok: true, data: rows[0], error: '' }
+        }
+      } catch (err) {
+        // 缓存失败,降级到文件读取
+      }
+    }
+
+    // 降级:直接读文件
+    const filePath = await this._findChapterFile(chapterNum)
+    if (!filePath) {
+      return {
+        ok: false,
+        data: null,
+        error: `章节 ${chapterNum} 不存在(未找到对应文件)`,
+      }
+    }
+
+    try {
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
+      if (!parsed.ok) {
+        return {
+          ok: false,
+          data: null,
+          error: `章节 ${chapterNum} 解析失败:${parsed.error}`,
+        }
+      }
+      return { ok: true, data: parsed.data, error: '' }
+    } catch (err) {
+      return {
+        ok: false,
+        data: null,
+        error: `读取章节 ${chapterNum} 失败:${err.message}`,
+      }
+    }
+  }
+
+  /**
+   * 读取章节正文(不含 front matter)。
+   * @param {number} chapterNum
+   * @returns {Promise<{ok: boolean, body: string, error: string}>}
+   */
+  async readBody(chapterNum) {
+    const filePath = await this._findChapterFile(chapterNum)
+    if (!filePath) {
+      return { ok: false, body: '', error: `章节 ${chapterNum} 不存在` }
+    }
+
+    try {
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
+      if (!parsed.ok) {
+        return { ok: false, body: '', error: `解析失败:${parsed.error}` }
+      }
+      return { ok: true, body: parsed.body, error: '' }
+    } catch (err) {
+      return { ok: false, body: '', error: err.message }
+    }
+  }
+
+  /**
+   * 读取章节正文末尾 N 字。
+   * @param {number} chapterNum
+   * @param {number} wordCount
+   * @returns {Promise<{ok: boolean, text: string, error: string}>}
+   */
+  async readTail(chapterNum, wordCount) {
+    const bodyResult = await this.readBody(chapterNum)
+    if (!bodyResult.ok) {
+      return { ok: false, text: '', error: bodyResult.error }
+    }
+
+    const tail = bodyResult.body.slice(-wordCount)
+    return { ok: true, text: tail, error: '' }
+  }
+
+  /**
+   * 读取章节正文开头 N 字。
+   * @param {number} chapterNum
+   * @param {number} wordCount
+   * @returns {Promise<{ok: boolean, text: string, error: string}>}
+   */
+  async readHead(chapterNum, wordCount) {
+    const bodyResult = await this.readBody(chapterNum)
+    if (!bodyResult.ok) {
+      return { ok: false, text: '', error: bodyResult.error }
+    }
+
+    const head = bodyResult.body.slice(0, wordCount)
+    return { ok: true, text: head, error: '' }
+  }
+
+  /**
+   * 批量读取章节范围,返回指定字段。
+   * @param {number} startChapter
+   * @param {number} endChapter
+   * @param {string[]} fields - 字段列表(默认 ['摘要'])
+   * @returns {Promise<{ok: boolean, chapters: object[], error: string}>}
+   */
+  async readRange(startChapter, endChapter, fields = ['摘要']) {
+    // 简化实现:逐个读取 front matter
+    const chapters = []
+    for (let i = startChapter; i <= endChapter; i++) {
+      const result = await this.readFrontMatter(i)
+      if (result.ok) {
+        const filtered = { 章号: i }
+        for (const field of fields) {
+          if (field in result.data) {
+            filtered[field] = result.data[field]
+          }
+        }
+        chapters.push(filtered)
+      }
+    }
+
+    return { ok: true, chapters, error: '' }
+  }
+
+  /**
+   * 查找章节文件路径(零填充前缀匹配)。
+   * @param {number} chapterNum
+   * @returns {Promise<string|null>}
+   */
+  async _findChapterFile(chapterNum) {
+    const chapterDir = path.join(this.repoPath, '定稿', '正文')
+    try {
+      const files = await fs.readdir(chapterDir)
+      const prefix = String(chapterNum).padStart(4, '0') // 0001-
+      const found = files.find((file) => file.startsWith(prefix + '-'))
+      return found ? path.join(chapterDir, found) : null
+    } catch (err) {
+      return null
+    }
+  }
+}

+ 30 - 0
v7/src/storage/adapters/ChapterWriter.js

@@ -0,0 +1,30 @@
+/**
+ * ChapterWriter:写新章到定稿(M2 落地,本任务只定接口占位)。
+ */
+export class ChapterWriter {
+  constructor(repoPath, cache = null) {
+    this.repoPath = repoPath
+    this.cache = cache
+  }
+
+  /**
+   * 写新章到定稿目录。
+   * @param {number} chapterNum
+   * @param {object} frontMatter
+   * @param {string} body
+   * @returns {Promise<{ok: boolean, error: string}>}
+   */
+  async writeChapter(chapterNum, frontMatter, body) {
+    throw new Error('ChapterWriter.writeChapter() 将在 M2 定稿流程中实现')
+  }
+
+  /**
+   * 更新章节 front matter。
+   * @param {number} chapterNum
+   * @param {object} updates
+   * @returns {Promise<{ok: boolean, error: string}>}
+   */
+  async updateFrontMatter(chapterNum, updates) {
+    throw new Error('ChapterWriter.updateFrontMatter() 将在 M2 定稿流程中实现')
+  }
+}

+ 144 - 0
v7/src/storage/adapters/EntityReader.js

@@ -0,0 +1,144 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseFrontMatter } from '../parsers/front-matter.js'
+import { parseMarkdownTable } from '../parsers/markdown-table.js'
+
+/**
+ * EntityReader:读取角色卡、解析别名。
+ */
+export class EntityReader {
+  constructor(repoPath, cache = null) {
+    this.repoPath = repoPath
+    this.cache = cache
+  }
+
+  /**
+   * 读取角色卡 front matter。
+   * @param {string} name - 正名
+   * @returns {Promise<{ok: boolean, data: object|null, error: string}>}
+   */
+  async readCharacterFrontMatter(name) {
+    const filePath = path.join(this.repoPath, '定稿', '设定', '角色', `${name}.md`)
+
+    try {
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
+      if (!parsed.ok) {
+        return { ok: false, data: null, error: `解析失败:${parsed.error}` }
+      }
+      return { ok: true, data: parsed.data, error: '' }
+    } catch (err) {
+      return { ok: false, data: null, error: `角色 ${name} 不存在` }
+    }
+  }
+
+  /**
+   * 读取角色卡完整内容(front matter + 正文)。
+   * @param {string} name
+   * @returns {Promise<{ok: boolean, frontMatter: object|null, body: string, error: string}>}
+   */
+  async readCharacterFull(name) {
+    const filePath = path.join(this.repoPath, '定稿', '设定', '角色', `${name}.md`)
+
+    try {
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
+      if (!parsed.ok) {
+        return { ok: false, frontMatter: null, body: '', error: `解析失败:${parsed.error}` }
+      }
+      return { ok: true, frontMatter: parsed.data, body: parsed.body, error: '' }
+    } catch (err) {
+      return { ok: false, frontMatter: null, body: '', error: `角色 ${name} 不存在` }
+    }
+  }
+
+  /**
+   * 解析别名,返回正名。
+   * @param {string} alias - 别名
+   * @returns {Promise<{ok: boolean, canonicalName: string|null, error: string}>}
+   */
+  async resolveAlias(alias) {
+    // 优先查缓存 entity_aliases 表
+    if (this.cache) {
+      try {
+        const rows = await this.cache.query(
+          'SELECT entity_id FROM entity_aliases WHERE alias = ?',
+          [alias]
+        )
+        if (rows.length > 0) {
+          return { ok: true, canonicalName: rows[0].entity_id, error: '' }
+        }
+      } catch (err) {
+        // 降级到文件读取
+      }
+    }
+
+    // 降级:解析名册文件
+    const rosterPath = path.join(this.repoPath, '定稿', '设定', '名册.md')
+    try {
+      const content = await fs.readFile(rosterPath, 'utf8')
+      const table = parseMarkdownTable(content)
+      if (!table.ok) {
+        return { ok: false, canonicalName: null, error: '名册解析失败' }
+      }
+
+      // 查找别名
+      for (const row of table.rows) {
+        const aliases = row.别名.split(',').map((a) => a.trim())
+        if (aliases.includes(alias)) {
+          return { ok: true, canonicalName: row.正名, error: '' }
+        }
+      }
+
+      return { ok: false, canonicalName: null, error: `未找到别名「${alias}」` }
+    } catch (err) {
+      return { ok: false, canonicalName: null, error: err.message }
+    }
+  }
+
+  /**
+   * 列出所有角色(按筛选条件)。
+   * @param {object} filter - 筛选条件(如 { status: "在世" })
+   * @returns {Promise<object[]>}
+   */
+  async listCharacters(filter = {}) {
+    if (this.cache) {
+      try {
+        let query = 'SELECT * FROM entities WHERE type = "character"'
+        const params = []
+
+        if (filter.status) {
+          query += ' AND status = ?'
+          params.push(filter.status)
+        }
+
+        const rows = await this.cache.query(query, params)
+        return rows
+      } catch (err) {
+        return []
+      }
+    }
+
+    // 降级:扫描角色目录
+    const charDir = path.join(this.repoPath, '定稿', '设定', '角色')
+    try {
+      const files = await fs.readdir(charDir)
+      const characters = []
+
+      for (const file of files) {
+        if (!file.endsWith('.md')) continue
+        const name = file.replace('.md', '')
+        const result = await this.readCharacterFrontMatter(name)
+        if (result.ok) {
+          if (!filter.status || result.data.状态 === filter.status) {
+            characters.push({ 正名: name, ...result.data })
+          }
+        }
+      }
+
+      return characters
+    } catch (err) {
+      return []
+    }
+  }
+}

+ 89 - 0
v7/src/storage/adapters/OutlineReader.js

@@ -0,0 +1,89 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+
+/**
+ * OutlineReader:读取总纲/卷纲。
+ */
+export class OutlineReader {
+  constructor(repoPath, cache = null) {
+    this.repoPath = repoPath
+    this.cache = cache
+  }
+
+  /**
+   * 读取总纲指定小节。
+   * @param {string} sectionTitle - 小节标题(如 "结局")
+   * @returns {Promise<{ok: boolean, content: string, error: string}>}
+   */
+  async readOutlineSection(sectionTitle) {
+    const outlinePath = path.join(this.repoPath, '大纲', '总纲.md')
+
+    try {
+      const content = await fs.readFile(outlinePath, 'utf8')
+      const section = this._extractSection(content, sectionTitle)
+      return { ok: true, content: section, error: '' }
+    } catch (err) {
+      return { ok: false, content: '', error: '总纲文件不存在' }
+    }
+  }
+
+  /**
+   * 读取卷纲全文。
+   * @param {number} volumeNum
+   * @returns {Promise<{ok: boolean, content: string, error: string}>}
+   */
+  async readVolumeOutline(volumeNum) {
+    const volStr = String(volumeNum).padStart(2, '0')
+    const volumePath = path.join(this.repoPath, '大纲', `第${volStr}卷.md`)
+
+    try {
+      const content = await fs.readFile(volumePath, 'utf8')
+      return { ok: true, content, error: '' }
+    } catch (err) {
+      return { ok: false, content: '', error: `第${volumeNum}卷卷纲不存在` }
+    }
+  }
+
+  /**
+   * 列出所有卷纲。
+   * @returns {Promise<number[]>}
+   */
+  async listVolumes() {
+    const outlineDir = path.join(this.repoPath, '大纲')
+
+    try {
+      const files = await fs.readdir(outlineDir)
+      const volumes = files
+        .filter((file) => file.match(/^第\d+卷\.md$/))
+        .map((file) => {
+          const match = file.match(/第(\d+)卷/)
+          return match ? parseInt(match[1], 10) : 0
+        })
+        .filter((v) => v > 0)
+        .sort((a, b) => a - b)
+
+      return volumes
+    } catch (err) {
+      return []
+    }
+  }
+
+  _extractSection(content, sectionTitle) {
+    const lines = content.split('\n')
+    let inSection = false
+    const sectionLines = []
+
+    for (const line of lines) {
+      if (line.startsWith('#')) {
+        if (inSection) break
+        if (line.includes(sectionTitle)) {
+          inSection = true
+          continue
+        }
+      }
+      if (inSection) sectionLines.push(line)
+    }
+
+    return sectionLines.join('\n').trim()
+  }
+}

+ 90 - 0
v7/src/storage/adapters/SecretReader.js

@@ -0,0 +1,90 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseFrontMatter } from '../parsers/front-matter.js'
+
+/**
+ * SecretReader:读取信息差。
+ */
+export class SecretReader {
+  constructor(repoPath, cache = null) {
+    this.repoPath = repoPath
+    this.cache = cache
+  }
+
+  async readBasicInfo(id) {
+    const filePath = await this._findSecretFile(id)
+    if (!filePath) {
+      return { ok: false, data: null, error: `信息差 ${id} 不存在` }
+    }
+
+    try {
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
+      if (!parsed.ok) {
+        return { ok: false, data: null, error: `解析失败:${parsed.error}` }
+      }
+      return { ok: true, data: parsed.data, error: '' }
+    } catch (err) {
+      return { ok: false, data: null, error: err.message }
+    }
+  }
+
+  async readContent(id) {
+    const filePath = await this._findSecretFile(id)
+    if (!filePath) {
+      return { ok: false, content: '', error: `信息差 ${id} 不存在` }
+    }
+
+    try {
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
+      if (!parsed.ok) {
+        return { ok: false, content: '', error: `解析失败:${parsed.error}` }
+      }
+
+      // 提取 "## 内容" 段落
+      const lines = parsed.body.split('\n')
+      let inContent = false
+      const contentLines = []
+
+      for (const line of lines) {
+        if (line.startsWith('## ')) {
+          if (inContent) break
+          if (line.includes('内容')) {
+            inContent = true
+            continue
+          }
+        }
+        if (inContent) contentLines.push(line)
+      }
+
+      return { ok: true, content: contentLines.join('\n').trim(), error: '' }
+    } catch (err) {
+      return { ok: false, content: '', error: err.message }
+    }
+  }
+
+  async listUnrevealed() {
+    if (!this.cache) return []
+
+    try {
+      const rows = await this.cache.query(
+        'SELECT * FROM secrets WHERE reader_knows = 0'
+      )
+      return rows
+    } catch (err) {
+      return []
+    }
+  }
+
+  async _findSecretFile(id) {
+    const secretDir = path.join(this.repoPath, '定稿', '设定', '信息差')
+    try {
+      const files = await fs.readdir(secretDir)
+      const found = files.find((file) => file.startsWith(id))
+      return found ? path.join(secretDir, found) : null
+    } catch (err) {
+      return null
+    }
+  }
+}

+ 290 - 0
v7/src/storage/adapters/ThreadLedgerReader.js

@@ -0,0 +1,290 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseFrontMatter } from '../parsers/front-matter.js'
+
+/**
+ * ThreadLedgerReader:读取三类条目(伏笔/悬念/感情线)。
+ */
+export class ThreadLedgerReader {
+  constructor(repoPath, cache = null) {
+    this.repoPath = repoPath
+    this.cache = cache
+  }
+
+  /**
+   * 读取条目基本信息。
+   * @param {string} threadId - 条目 ID(如 "伏笔-001")
+   * @returns {Promise<{ok: boolean, data: object|null, error: string}>}
+   */
+  async readBasicInfo(threadId) {
+    // 优先查缓存
+    if (this.cache) {
+      try {
+        const rows = await this.cache.query(
+          'SELECT * FROM threads WHERE id = ?',
+          [threadId]
+        )
+        if (rows.length > 0) {
+          return { ok: true, data: rows[0], error: '' }
+        }
+      } catch (err) {
+        // 降级到文件读取
+      }
+    }
+
+    // 降级:读文件
+    const filePath = await this._findThreadFile(threadId)
+    if (!filePath) {
+      return { ok: false, data: null, error: `条目 ${threadId} 不存在` }
+    }
+
+    try {
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
+      if (!parsed.ok) {
+        return { ok: false, data: null, error: `解析失败:${parsed.error}` }
+      }
+      return { ok: true, data: parsed.data, error: '' }
+    } catch (err) {
+      return { ok: false, data: null, error: err.message }
+    }
+  }
+
+  /**
+   * 读取条目履历。
+   * @param {string} threadId
+   * @returns {Promise<{ok: boolean, history: object[], error: string}>}
+   */
+  async readHistory(threadId) {
+    const filePath = await this._findThreadFile(threadId)
+    if (!filePath) {
+      return { ok: false, history: [], error: `条目 ${threadId} 不存在` }
+    }
+
+    try {
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
+      if (!parsed.ok) {
+        return { ok: false, history: [], error: `解析失败:${parsed.error}` }
+      }
+
+      // 从正文提取 "## 履历" 段落
+      const historySection = this._extractSection(parsed.body, '履历')
+      if (!historySection) {
+        return { ok: true, history: [], error: '' }
+      }
+
+      // 解析履历行(- 第N章:动作——描述(见...))
+      const history = this._parseHistoryLines(historySection)
+      return { ok: true, history, error: '' }
+    } catch (err) {
+      return { ok: false, history: [], error: err.message }
+    }
+  }
+
+  /**
+   * 读取条目收尾计划。
+   * @param {string} threadId
+   * @returns {Promise<{ok: boolean, plan: string, error: string}>}
+   */
+  async readClosurePlan(threadId) {
+    const filePath = await this._findThreadFile(threadId)
+    if (!filePath) {
+      return { ok: false, plan: '', error: `条目 ${threadId} 不存在` }
+    }
+
+    try {
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
+      if (!parsed.ok) {
+        return { ok: false, plan: '', error: `解析失败:${parsed.error}` }
+      }
+
+      const plan = this._extractSection(parsed.body, '收尾计划')
+      return { ok: true, plan: plan || '', error: '' }
+    } catch (err) {
+      return { ok: false, plan: '', error: err.message }
+    }
+  }
+
+  /**
+   * 读取条目描述。
+   * @param {string} threadId
+   * @returns {Promise<{ok: boolean, description: string, error: string}>}
+   */
+  async readDescription(threadId) {
+    const filePath = await this._findThreadFile(threadId)
+    if (!filePath) {
+      return { ok: false, description: '', error: `条目 ${threadId} 不存在` }
+    }
+
+    try {
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
+      if (!parsed.ok) {
+        return { ok: false, description: '', error: `解析失败:${parsed.error}` }
+      }
+
+      const description = this._extractSection(parsed.body, '描述')
+      return { ok: true, description: description || '', error: '' }
+    } catch (err) {
+      return { ok: false, description: '', error: err.message }
+    }
+  }
+
+  /**
+   * 列出悬了太久的条目。
+   * @param {object} bookConfig - book.yaml 配置
+   * @returns {Promise<object[]>}
+   */
+  async listOverdue(bookConfig) {
+    // 需要缓存或扫描文件计算最大章号
+    // 简化实现:假设从缓存读取
+    if (!this.cache) {
+      return []
+    }
+
+    try {
+      const maxChapterRows = await this.cache.query(
+        'SELECT MAX(chapter_num) as max FROM chapters'
+      )
+      const maxChapter = maxChapterRows[0]?.max || 0
+
+      const overdueThreads = await this.cache.query(
+        `SELECT id, type, short_title, last_advanced_chapter,
+                (? - last_advanced_chapter) as overdue_count
+         FROM threads
+         WHERE status = '进行'`,
+        [maxChapter]
+      )
+
+      // 按 book.yaml 阈值过滤
+      const filtered = overdueThreads.filter((t) => {
+        const typeKey = `${this._typeNameMap(t.type)}悬了太久章数`
+        const threshold = bookConfig[typeKey] || 10
+        return t.overdue_count > threshold
+      })
+
+      return filtered
+    } catch (err) {
+      return []
+    }
+  }
+
+  /**
+   * 按类型列出条目。
+   * @param {string} type - "foreshadow" / "suspense" / "romance"
+   * @param {string|null} status - "进行" / "已收尾" / "已放弃"
+   * @returns {Promise<object[]>}
+   */
+  async listByType(type, status = null) {
+    if (!this.cache) {
+      return []
+    }
+
+    try {
+      let query = 'SELECT id, short_title, strength, status FROM threads WHERE type = ?'
+      const params = [type]
+
+      if (status) {
+        query += ' AND status = ?'
+        params.push(status)
+      }
+
+      const rows = await this.cache.query(query, params)
+      return rows
+    } catch (err) {
+      return []
+    }
+  }
+
+  /**
+   * 查找条目文件路径。
+   * @param {string} threadId
+   * @returns {Promise<string|null>}
+   */
+  async _findThreadFile(threadId) {
+    // 解析 ID(伏笔-001 → 伏笔)
+    const type = threadId.split('-')[0]
+    const threadDir = path.join(this.repoPath, '大纲', type)
+
+    try {
+      const files = await fs.readdir(threadDir)
+      const found = files.find((file) => file.startsWith(threadId))
+      return found ? path.join(threadDir, found) : null
+    } catch (err) {
+      return null
+    }
+  }
+
+  /**
+   * 从正文提取指定 ## 段落内容。
+   * @param {string} body
+   * @param {string} sectionTitle
+   * @returns {string}
+   */
+  _extractSection(body, sectionTitle) {
+    const lines = body.split('\n')
+    let inSection = false
+    const sectionLines = []
+
+    for (const line of lines) {
+      if (line.startsWith('## ')) {
+        if (inSection) {
+          break // 遇到下一个 ## 停止
+        }
+        if (line.includes(sectionTitle)) {
+          inSection = true
+          continue
+        }
+      }
+
+      if (inSection) {
+        sectionLines.push(line)
+      }
+    }
+
+    return sectionLines.join('\n').trim()
+  }
+
+  /**
+   * 解析履历行(- 第N章:动作——描述(见...))。
+   * @param {string} historyText
+   * @returns {object[]}
+   */
+  _parseHistoryLines(historyText) {
+    const lines = historyText.split('\n')
+    const history = []
+
+    for (const line of lines) {
+      const trimmed = line.trim()
+      if (!trimmed.startsWith('-')) continue
+
+      const content = trimmed.slice(1).trim()
+      // 简化解析:提取章号
+      const match = content.match(/第(\d+)章/)
+      if (match) {
+        history.push({
+          章号: parseInt(match[1], 10),
+          原文: content,
+        })
+      }
+    }
+
+    return history
+  }
+
+  /**
+   * 类型名称映射(type → 中文)。
+   * @param {string} type
+   * @returns {string}
+   */
+  _typeNameMap(type) {
+    const map = {
+      foreshadow: '伏笔',
+      suspense: '悬念',
+      romance: '感情线',
+    }
+    return map[type] || type
+  }
+}

+ 29 - 0
v7/src/storage/adapters/ThreadLedgerWriter.js

@@ -0,0 +1,29 @@
+/**
+ * ThreadLedgerWriter:更新条目、追加履历(M2 落地,本任务只定接口占位)。
+ */
+export class ThreadLedgerWriter {
+  constructor(repoPath, cache = null) {
+    this.repoPath = repoPath
+    this.cache = cache
+  }
+
+  /**
+   * 更新条目状态。
+   * @param {string} threadId
+   * @param {object} updates
+   * @returns {Promise<{ok: boolean, error: string}>}
+   */
+  async updateThread(threadId, updates) {
+    throw new Error('ThreadLedgerWriter.updateThread() 将在 M2 定稿流程中实现')
+  }
+
+  /**
+   * 追加履历条目。
+   * @param {string} threadId
+   * @param {object} historyEntry
+   * @returns {Promise<{ok: boolean, error: string}>}
+   */
+  async appendHistory(threadId, historyEntry) {
+    throw new Error('ThreadLedgerWriter.appendHistory() 将在 M2 定稿流程中实现')
+  }
+}

+ 68 - 0
v7/src/storage/adapters/TimelineReader.js

@@ -0,0 +1,68 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseMarkdownTable } from '../parsers/markdown-table.js'
+
+/**
+ * TimelineReader:读取时间线。
+ */
+export class TimelineReader {
+  constructor(repoPath, cache = null) {
+    this.repoPath = repoPath
+    this.cache = cache
+  }
+
+  /**
+   * 读取当前卷时间线。
+   * @param {number} volumeNum - 卷号
+   * @returns {Promise<{ok: boolean, timeline: object[], error: string}>}
+   */
+  async readCurrentVolume(volumeNum) {
+    return this.readVolumeRange(volumeNum, volumeNum)
+  }
+
+  /**
+   * 读取卷范围时间线。
+   * @param {number} startVolume
+   * @param {number} endVolume
+   * @returns {Promise<{ok: boolean, timeline: object[], error: string}>}
+   */
+  async readVolumeRange(startVolume, endVolume) {
+    const allTimeline = []
+
+    for (let vol = startVolume; vol <= endVolume; vol++) {
+      const volStr = String(vol).padStart(2, '0')
+      const filePath = path.join(
+        this.repoPath,
+        '定稿',
+        '设定',
+        '时间线',
+        `第${volStr}卷.md`
+      )
+
+      try {
+        const content = await fs.readFile(filePath, 'utf8')
+        const table = parseMarkdownTable(content)
+        if (table.ok) {
+          allTimeline.push(...table.rows)
+        }
+      } catch (err) {
+        // 文件不存在,跳过
+      }
+    }
+
+    return { ok: true, timeline: allTimeline, error: '' }
+  }
+
+  /**
+   * 按在场角色筛选时间线。
+   * @param {number} volumeNum
+   * @param {string} name - 角色正名
+   * @returns {Promise<object[]>}
+   */
+  async readByParticipant(volumeNum, name) {
+    const result = await this.readCurrentVolume(volumeNum)
+    if (!result.ok) return []
+
+    return result.timeline.filter((entry) => entry.在场 && entry.在场.includes(name))
+  }
+}

+ 11 - 3
v7/src/storage/index.js

@@ -1,3 +1,11 @@
-// 存储适配器:容错读写 story repo 源文件的小端口(ChapterReader/Writer、ThreadLedger 等)。
-// 占位——真实实现见 M1(spec §1.5 架构原则:拆小端口,AI 只吃 DTO)。
-export {}
+// Storage Adapter 小端口统一导出
+
+export { ChapterReader } from './adapters/ChapterReader.js'
+export { ChapterWriter } from './adapters/ChapterWriter.js'
+export { ThreadLedgerReader } from './adapters/ThreadLedgerReader.js'
+export { ThreadLedgerWriter } from './adapters/ThreadLedgerWriter.js'
+export { EntityReader } from './adapters/EntityReader.js'
+export { TimelineReader } from './adapters/TimelineReader.js'
+export { SecretReader } from './adapters/SecretReader.js'
+export { OutlineReader } from './adapters/OutlineReader.js'
+export { BookConfigReader } from './adapters/BookConfigReader.js'

+ 14 - 0
v7/test/fixtures/sample-book/book.yaml

@@ -0,0 +1,14 @@
+spec_version: "7.0"
+书名: 测试书
+类型: 玄幻
+每章目标字数: 3000
+卷规模: 40
+文体基线起: 1
+文体基线止: 30
+伏笔悬了太久章数: 10
+悬念悬了太久章数: 10
+感情线悬了太久章数: 30
+连续弱钩上限: 3
+关键章稿数: 3
+自动确认细纲: false
+连写批次大小: 8

+ 15 - 0
v7/test/fixtures/sample-book/大纲/伏笔/伏笔-001-神秘老者.md

@@ -0,0 +1,15 @@
+---
+强度: 高
+状态: 进行
+开启章: 1
+预计收尾: 第3卷
+最后推进章: 1
+---
+## 描述
+神秘老者的真实身份。
+
+## 收尾计划
+第三卷揭晓:曾是宗门长老,因禁术被逐。
+
+## 履历
+- 第1章:埋下——神秘老者留玉佩(见本章结尾黑影段落)

+ 1 - 0
v7/test/fixtures/sample-book/定稿/摘要/章摘要/0001.md

@@ -0,0 +1 @@
+林晚初入青云宗,获得青色令牌成为外门弟子。夜晚发现桌上神秘玉佩,窗外黑影闪过。

+ 28 - 0
v7/test/fixtures/sample-book/定稿/正文/0001-开局.md

@@ -0,0 +1,28 @@
+---
+章号: 1
+标题: 开局
+卷: 1
+视角: 林晚
+书内时间: 大历1023年春月初一
+字数: 2800
+章定位: 推进
+钩子: 危机钩-强
+情绪定位: 铺垫
+伏笔:
+  - 埋下 伏笔-001
+本章要写到的事:
+  - 林晚初入宗门
+  - 神秘老者出现
+---
+
+林晚抬头望着宗门大殿,心中忐忑不安。
+
+大殿前人影绰绰,数百名新入门弟子在此等候。
+
+"林晚,林晚在吗?"执事长老唤着名字。
+
+"弟子在!"她快步上前,从长老手中接过青色令牌。
+
+夜幕降临,新弟子各自回到住处。林晚推开房门,却见桌上多了一枚古朴玉佩。
+
+"这是……"她刚拿起玉佩,窗外一道黑影闪过,消失在夜色中。

+ 23 - 0
v7/test/fixtures/sample-book/定稿/正文/0002-初遇.md

@@ -0,0 +1,23 @@
+---
+章号: 2
+标题: 初遇
+卷: 1
+视角: 林晚
+书内时间: 大历1023年春月初二
+字数: 2600
+章定位: 推进
+钩子: 悬念钩-中
+情绪定位: 铺垫
+本章要写到的事:
+  - 林晚寻找玉佩线索
+---
+
+林晚握着玉佩,思索了一夜。
+
+清晨,她决定去藏书阁查阅典籍。
+
+路上遇到大师兄,"林师妹,这玉佩……"大师兄面露惊讶。
+
+"师兄认得此物?"
+
+"此乃宗门禁物,你从何得来?"

+ 16 - 0
v7/test/fixtures/sample-book/定稿/设定/信息差/信息差-001-灭门真凶.md

@@ -0,0 +1,16 @@
+---
+强度: 高
+状态: 进行
+谁知道:
+  - 林晚
+读者知道: false
+登记章: 1
+关键词:
+  - 玉佩
+  - 宗门
+---
+## 描述
+玉佩为何是宗门禁物。
+
+## 内容
+玉佩乃前代掌门封印邪灵之物,外人不可知。

+ 4 - 0
v7/test/fixtures/sample-book/定稿/设定/名册.md

@@ -0,0 +1,4 @@
+| 正名 | 别名 | 类型 | 首现章 |
+|------|------|------|---------|
+| 林晚 | 晚晚, 林师妹 | character | 1 |
+| 神秘老者 | 黑衣人 | character | 1 |

+ 4 - 0
v7/test/fixtures/sample-book/定稿/设定/时间线/第01卷.md

@@ -0,0 +1,4 @@
+| 章 | 书内时间 | 一句话事件 | 在场 |
+|----|----------|------------|------|
+| 1 | 1023春月初一 | 林晚入宗门,神秘老者留玉佩 | 林晚 |
+| 2 | 1023春月初二 | 林晚寻找玉佩线索 | 林晚 |

+ 20 - 0
v7/test/fixtures/sample-book/定稿/设定/角色/林晚.md

@@ -0,0 +1,20 @@
+---
+姓名: 林晚
+别名:
+  - 晚晚
+  - 林师妹
+状态: 在世
+位置: 青云宗
+境界: 练气三层
+持有:
+  - 青霜剑
+最后变更章: 1
+---
+## 设定
+外门弟子,天赋中等但心性坚韧。
+
+## 典型对话
+"本姑娘才不怕!"
+
+## 关系
+与大师兄关系微妙。

+ 65 - 0
v7/test/storage/adapters/ChapterReader.test.js

@@ -0,0 +1,65 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { ChapterReader } from '../../../src/storage/adapters/ChapterReader.js'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const fixtureRoot = path.join(__dirname, '../../fixtures/sample-book')
+
+test('readFrontMatter:读取章节 front matter', async () => {
+  const reader = new ChapterReader(fixtureRoot)
+  const result = await reader.readFrontMatter(1)
+
+  assert.equal(result.ok, true)
+  assert.equal(result.data.章号, 1)
+  assert.equal(result.data.标题, '开局')
+  assert.equal(result.data.卷, 1)
+  assert.equal(result.data.视角, '林晚')
+})
+
+test('readFrontMatter:不存在的章号', async () => {
+  const reader = new ChapterReader(fixtureRoot)
+  const result = await reader.readFrontMatter(999)
+
+  assert.equal(result.ok, false)
+  assert.ok(result.error.includes('不存在'))
+})
+
+test('readBody:读取章节正文', async () => {
+  const reader = new ChapterReader(fixtureRoot)
+  const result = await reader.readBody(1)
+
+  assert.equal(result.ok, true)
+  assert.ok(result.body.includes('林晚抬头望着宗门大殿'))
+  assert.ok(result.body.includes('消失在夜色中'))
+  assert.ok(!result.body.includes('---')) // 不含 front matter
+})
+
+test('readTail:读取章节末尾 N 字', async () => {
+  const reader = new ChapterReader(fixtureRoot)
+  const result = await reader.readTail(1, 50)
+
+  assert.equal(result.ok, true)
+  assert.ok(result.text.includes('消失在夜色中'))
+  assert.ok(result.text.length <= 60) // 允许少量误差(字符 vs 字)
+})
+
+test('readHead:读取章节开头 N 字', async () => {
+  const reader = new ChapterReader(fixtureRoot)
+  const result = await reader.readHead(1, 20)
+
+  assert.equal(result.ok, true)
+  assert.ok(result.text.includes('林晚抬头'))
+})
+
+test('readRange:批量读取章号范围', async () => {
+  const reader = new ChapterReader(fixtureRoot)
+  const result = await reader.readRange(1, 2, ['标题', '卷'])
+
+  assert.equal(result.ok, true)
+  assert.equal(result.chapters.length, 2)
+  assert.equal(result.chapters[0].章号, 1)
+  assert.equal(result.chapters[0].标题, '开局')
+  assert.equal(result.chapters[1].标题, '初遇')
+})

+ 61 - 0
v7/test/storage/adapters/EntityReader.test.js

@@ -0,0 +1,61 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { EntityReader } from '../../../src/storage/adapters/EntityReader.js'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const fixtureRoot = path.join(__dirname, '../../fixtures/sample-book')
+
+test('readCharacterFrontMatter:读取角色卡 front matter', async () => {
+  const reader = new EntityReader(fixtureRoot)
+  const result = await reader.readCharacterFrontMatter('林晚')
+
+  assert.equal(result.ok, true)
+  assert.equal(result.data.姓名, '林晚')
+  assert.equal(result.data.状态, '在世')
+  assert.equal(result.data.位置, '青云宗')
+  assert.deepEqual(result.data.别名, ['晚晚', '林师妹'])
+})
+
+test('readCharacterFull:读取角色卡完整内容', async () => {
+  const reader = new EntityReader(fixtureRoot)
+  const result = await reader.readCharacterFull('林晚')
+
+  assert.equal(result.ok, true)
+  assert.equal(result.frontMatter.姓名, '林晚')
+  assert.ok(result.body.includes('外门弟子'))
+  assert.ok(result.body.includes('本姑娘才不怕'))
+})
+
+test('resolveAlias:解析别名到正名', async () => {
+  const reader = new EntityReader(fixtureRoot)
+  const result = await reader.resolveAlias('晚晚')
+
+  assert.equal(result.ok, true)
+  assert.equal(result.canonicalName, '林晚')
+})
+
+test('resolveAlias:未找到别名', async () => {
+  const reader = new EntityReader(fixtureRoot)
+  const result = await reader.resolveAlias('不存在的别名')
+
+  assert.equal(result.ok, false)
+  assert.ok(result.error.includes('未找到'))
+})
+
+test('listCharacters:列出所有角色', async () => {
+  const reader = new EntityReader(fixtureRoot)
+  const characters = await reader.listCharacters()
+
+  assert.ok(characters.length > 0)
+  assert.ok(characters.some((c) => c.正名 === '林晚'))
+})
+
+test('listCharacters:按状态筛选', async () => {
+  const reader = new EntityReader(fixtureRoot)
+  const characters = await reader.listCharacters({ status: '在世' })
+
+  assert.ok(characters.length > 0)
+  assert.ok(characters.every((c) => c.状态 === '在世'))
+})

+ 53 - 0
v7/test/storage/adapters/ThreadLedgerReader.test.js

@@ -0,0 +1,53 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { ThreadLedgerReader } from '../../../src/storage/adapters/ThreadLedgerReader.js'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const fixtureRoot = path.join(__dirname, '../../fixtures/sample-book')
+
+test('readBasicInfo:读取伏笔基本信息', async () => {
+  const reader = new ThreadLedgerReader(fixtureRoot)
+  const result = await reader.readBasicInfo('伏笔-001')
+
+  assert.equal(result.ok, true)
+  assert.equal(result.data.强度, '高')
+  assert.equal(result.data.状态, '进行')
+  assert.equal(result.data.开启章, 1)
+  assert.equal(result.data.预计收尾, '第3卷')
+})
+
+test('readBasicInfo:不存在的条目', async () => {
+  const reader = new ThreadLedgerReader(fixtureRoot)
+  const result = await reader.readBasicInfo('伏笔-999')
+
+  assert.equal(result.ok, false)
+  assert.ok(result.error.includes('不存在'))
+})
+
+test('readHistory:读取履历', async () => {
+  const reader = new ThreadLedgerReader(fixtureRoot)
+  const result = await reader.readHistory('伏笔-001')
+
+  assert.equal(result.ok, true)
+  assert.equal(result.history.length, 1)
+  assert.equal(result.history[0].章号, 1)
+  assert.ok(result.history[0].原文.includes('埋下'))
+})
+
+test('readClosurePlan:读取收尾计划', async () => {
+  const reader = new ThreadLedgerReader(fixtureRoot)
+  const result = await reader.readClosurePlan('伏笔-001')
+
+  assert.equal(result.ok, true)
+  assert.ok(result.plan.includes('第三卷揭晓'))
+})
+
+test('readDescription:读取描述', async () => {
+  const reader = new ThreadLedgerReader(fixtureRoot)
+  const result = await reader.readDescription('伏笔-001')
+
+  assert.equal(result.ok, true)
+  assert.ok(result.description.includes('神秘老者的真实身份'))
+})