Преглед на файлове

feat(v7): 机检条目形式检查/备料信息差边界/ReviewInput 补料

- 共用解析 util/thread-declarations:front matter 三数组「动词 编号」声明,机检与两审共用不双写
- 机检第 8 项 checkThreadDeclarations:类型一致/开启撞号/非开启须存在且状态=进行,blocking 与禁词同级
- 备料信息差边界注入 短题+知情人+关键词+内容首句(只给编号等于没给)
- rebuilder 解析 secrets.short_title 短题;SecretReader.listUnrevealed 扩返回+readContentFirstLine
- ReviewInput 增 拟条目变动 与 声明条目履历尾部(末 3 行);事实审查角色输入清单同步
- 新增 12 测试用例,全量 292 绿;build-host-shells --check 通过
lingfengQAQ преди 7 часа
родител
ревизия
54f59d4cf4

+ 1 - 1
v7/roles/事实审查.md

@@ -7,7 +7,7 @@ description: 读整章草稿,逐维度核出可验证的事实/一致性/逻
 你是事实审查员。只用传入的 ReviewInput 核对草稿,逐维度找出可验证的问题,输出问题清单 JSON。不读文件、不调脚本。
 
 ## 输入(ReviewInput)
-章号、草稿全文、本章要写到的事、全书近况、相关角色(境界/状态/位置/持有)、时间线片段、信息差候选。
+章号、草稿全文、本章要写到的事、全书近况、相关角色(境界/状态/位置/持有)、相关条目(含拟变动与近期履历)、时间线片段、信息差候选。
 
 ## 维度(category)
 - setting:能力/境界/地点/物品与相关角色及已建立规则一致

+ 4 - 2
v7/src/cache/rebuilder.js

@@ -197,11 +197,13 @@ async function scanSecrets(repoPath, db) {
 
       if (parsed.ok) {
         const fm = parsed.data
-        const id = file.replace('.md', '').split('-').slice(0, 2).join('-')
+        const stem = file.replace('.md', '')
+        const id = stem.split('-').slice(0, 2).join('-')
+        const shortTitle = stem.split('-').slice(2).join('-') || id // 信息差-021-灭门真凶 → 灭门真凶
 
         insertStmt.run(
           id,
-          id,
+          shortTitle,
           JSON.stringify(fm.知情人 || []),
           fm.读者已知 ? 1 : 0,
           fm.登记章 || 1,

+ 2 - 2
v7/src/commands/list-secrets.js

@@ -14,8 +14,8 @@ export async function run(args, options, ctx) {
 
   const out = rows.map((s) => ({
     id: s.id,
-    short_title: s.short_title,
-    蓄积章数: max - s.registered_chapter,
+    short_title: s.短题,
+    蓄积章数: max - s.登记章,
   }))
   return { ok: true, output: JSON.stringify(out, null, 2) }
 }

+ 42 - 2
v7/src/mechanical-check/index.js

@@ -2,13 +2,14 @@ import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { parseFrontMatter } from '../storage/parsers/front-matter.js'
 import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
+import { parseThreadDeclarations, VERBS, OPENING_VERBS } from '../util/thread-declarations.js'
 
 // front matter 章档案必填字段(§4.1 机器消费部分)
 const REQUIRED_FM = ['章号', '标题', '卷', '字数', '章定位', '钩子', '情绪定位']
 
 /**
- * 机检:零 token 可计数项(D2 七项)。不过关(pass=false)= 存在阻断 issue
- * 新专名/信息差关键词只出候选(candidates),不拦截。
+ * 机检:零 token 可计数项(D2 七项 + 条目变动形式检查,spec 0.9 §8 第 5 步)
+ * 不过关(pass=false)= 存在阻断 issue。新专名/信息差关键词只出候选(candidates),不拦截。
  * @param {{repoPath: string, cache: object}} ctx
  * @param {{chapterNum: number, draftPath: string}} args
  * @returns {Promise<{ok: boolean, pass: boolean, issues: object[], candidates: object[], error: string}>}
@@ -35,6 +36,7 @@ export async function mechanicalCheck(ctx, { chapterNum, draftPath }) {
     await checkNewProperNouns(body, cache, candidates) // 5(候选)
     checkFrontMatter(parsed, fm, issues) // 6
     await checkSecretKeywords(body, cache, candidates) // 7(候选)
+    await checkThreadDeclarations(fm, cache, issues) // 8(条目变动,只查形式)
 
     return { ok: true, pass: issues.length === 0, issues, candidates, error: '' }
   } catch (err) {
@@ -169,3 +171,41 @@ function checkFrontMatter(parsed, fm, issues) {
     issues.push({ check: 'front matter', severity: 'high', blocking: true, description: `front matter 缺字段:${missing.join('、')}` })
   }
 }
+
+// 条目变动形式检查(spec 0.9 §8 第 5 步;查 threads 表,零语义):
+// ①类型一致 ②开启类动词不得撞已有编号 ③非开启动词要求条目存在且状态=进行
+async function checkThreadDeclarations(fm, cache, issues) {
+  const { declarations, malformed } = parseThreadDeclarations(fm)
+  for (const bad of malformed) {
+    issues.push({ check: '条目变动', severity: 'high', blocking: true, description: `条目声明格式应为「动词 编号」:${bad}` })
+  }
+  if (!declarations.length) return
+
+  const known = new Map()
+  try {
+    for (const t of await cache.query('SELECT id, status FROM threads')) known.set(t.id, t.status)
+  } catch {
+    return // 无缓存,跳过(形式检查依赖条目表)
+  }
+
+  for (const d of declarations) {
+    if (!d.id.startsWith(`${d.type}-`)) {
+      issues.push({ check: '条目变动', severity: 'high', blocking: true, description: `「${d.type}」清单里出现异类编号「${d.id}」` })
+      continue
+    }
+    if (!VERBS[d.type].includes(d.verb)) {
+      issues.push({ check: '条目变动', severity: 'high', blocking: true, description: `「${d.type}」没有动词「${d.verb}」(${d.raw}),合法动词:${VERBS[d.type].join('/')}` })
+      continue
+    }
+    const status = known.get(d.id)
+    if (OPENING_VERBS.has(d.verb)) {
+      if (status !== undefined) {
+        issues.push({ check: '条目变动', severity: 'high', blocking: true, description: `「${d.raw}」:${d.id} 已存在(状态:${status}),开新条目须用新编号` })
+      }
+    } else if (status === undefined) {
+      issues.push({ check: '条目变动', severity: 'high', blocking: true, description: `「${d.raw}」:${d.id} 不存在,疑似编号笔误` })
+    } else if (status !== '进行') {
+      issues.push({ check: '条目变动', severity: 'high', blocking: true, description: `「${d.raw}」:${d.id} 状态是「${status}」,不能再「${d.verb}」` })
+    }
+  }
+}

+ 13 - 5
v7/src/prep/index.js

@@ -39,11 +39,19 @@ export async function prepareChapterMaterials(ctx, { chapterNum }) {
         ? tl.timeline.map((row) => `- ${row.章 ?? ''} ${row.一句话事件 ?? ''}`).join('\n')
         : '(无)'
 
-    // 信息差边界(未揭晓,勿泄)
-    const secrets = await new SecretReader(repoPath, cache).listUnrevealed()
-    const 信息差md = secrets.length
-      ? secrets.map((s) => `- ${s.id}(读者未知,本章勿泄)`).join('\n')
-      : '(无)'
+    // 信息差边界(未揭晓,勿泄):短题+知情人+关键词+内容首句——写稿 AI 知道秘密才守得住秘密
+    const secretReader = new SecretReader(repoPath, cache)
+    const secrets = await secretReader.listUnrevealed()
+    const 信息差行 = []
+    for (const s of secrets) {
+      const fl = await secretReader.readContentFirstLine(s.id)
+      const 知情人 = s.知情人.length ? s.知情人.join('、') : '(未登记)'
+      const 关键词 = s.关键词.length ? s.关键词.join('/') : '(无)'
+      信息差行.push(
+        `- ${s.id}(${s.短题}):知情人=${知情人};关键词=${关键词};内容:${fl.line || '(未读到)'}——读者未知,除知情人的对话与视角外不得出现`
+      )
+    }
+    const 信息差md = 信息差行.length ? 信息差行.join('\n') : '(无)'
 
     // 近章结尾(近 2 章末尾 150 字,反复读防接不上)
     const recent = await cache.query(

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

@@ -2,9 +2,12 @@ import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { assembleBookStatus } from '../prep/book-status.js'
 import { extractSection } from '../util/markdown.js'
+import { parseThreadDeclarations } from '../util/thread-declarations.js'
 import { assembleCharacterContext } from '../dto/character-context.js'
+import { parseFrontMatter } from '../storage/parsers/front-matter.js'
 import { TimelineReader } from '../storage/adapters/TimelineReader.js'
 import { SecretReader } from '../storage/adapters/SecretReader.js'
+import { ThreadLedgerReader } from '../storage/adapters/ThreadLedgerReader.js'
 import { writeAtomicBatch } from '../storage/atomic.js'
 import { validateReviewReport } from './schema.js'
 
@@ -23,6 +26,11 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
 
     const 草稿全文 = await fs.readFile(path.join(repoPath, draftPath), 'utf8')
 
+    // 拟条目变动:草稿 front matter 三数组声明(与机检共用同一解析,不双写)
+    const draftFm = parseFrontMatter(草稿全文)
+    const { declarations } = parseThreadDeclarations(draftFm.ok ? draftFm.data : null)
+    const 拟条目变动 = declarations.map(({ type, verb, id }) => ({ type, verb, id }))
+
     let 本章要写到的事 = '(无细纲)'
     try {
       const outline = await fs.readFile(path.join(repoPath, '工作区', '细纲.md'), 'utf8')
@@ -76,7 +84,7 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
     const 时间线片段 = tl.ok ? tl.timeline.map((row) => ({ 章: row.章 ?? '', 事件: row.一句话事件 ?? '' })) : []
 
     const secrets = await new SecretReader(repoPath, cache).listUnrevealed()
-    const 信息差候选 = secrets.map((s) => ({ id: s.id, 关键词: s.关键词 ?? s.keyword ?? '' }))
+    const 信息差候选 = secrets.map((s) => ({ id: s.id, 短题: s.短题, 关键词: s.关键词 }))
 
     // P1-1:相关条目 = 仍在进行、且在本章前开启的条目(不泄漏 file_path)
     let 相关条目 = []
@@ -97,6 +105,17 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
       // 缓存不可用则略
     }
 
+    // 草稿声明涉及的条目附履历尾部(末 3 行);未声明的维持纯元数据(控 token)
+    const declaredIds = new Set(拟条目变动.map((d) => d.id))
+    if (declaredIds.size) {
+      const ledger = new ThreadLedgerReader(repoPath, cache)
+      for (const t of 相关条目) {
+        if (!declaredIds.has(t.id)) continue
+        const h = await ledger.readHistory(t.id)
+        if (h.ok && h.history.length) t.履历尾部 = h.history.slice(-3).map((x) => x.原文)
+      }
+    }
+
     return {
       ok: true,
       input: {
@@ -106,6 +125,7 @@ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
         全书近况: status.ok ? status.markdown : '',
         相关角色,
         相关条目,
+        拟条目变动,
         名册,
         时间线片段,
         信息差候选,

+ 33 - 2
v7/src/storage/adapters/SecretReader.js

@@ -2,6 +2,15 @@ import { promises as fs } from 'node:fs'
 import path from 'node:path'
 import { parseFrontMatter } from '../parsers/front-matter.js'
 
+function parseJSONArray(text) {
+  try {
+    const v = JSON.parse(text || '[]')
+    return Array.isArray(v) ? v : []
+  } catch {
+    return []
+  }
+}
+
 /**
  * SecretReader:读取信息差。
  */
@@ -64,19 +73,41 @@ export class SecretReader {
     }
   }
 
+  /**
+   * 列出未揭晓信息差(缓存直出,供备料"信息差边界"与两审"信息差候选")。
+   * @returns {Promise<Array<{id: string, 短题: string, 知情人: string[], 关键词: string[], 登记章: number}>>}
+   */
   async listUnrevealed() {
     if (!this.cache) return []
 
     try {
       const rows = await this.cache.query(
-        'SELECT * FROM secrets WHERE reader_knows = 0'
+        'SELECT id, short_title, known_to, keywords, registered_chapter FROM secrets WHERE reader_knows = 0'
       )
-      return rows
+      return rows.map((r) => ({
+        id: r.id,
+        短题: r.short_title || r.id,
+        知情人: parseJSONArray(r.known_to),
+        关键词: parseJSONArray(r.keywords),
+        登记章: r.registered_chapter,
+      }))
     } catch (err) {
       return []
     }
   }
 
+  /**
+   * 读「## 内容」段首行(精准片段,备料注入用)。
+   * @param {string} id
+   * @returns {Promise<{ok: boolean, line: string, error: string}>}
+   */
+  async readContentFirstLine(id) {
+    const r = await this.readContent(id)
+    if (!r.ok) return { ok: false, line: '', error: r.error }
+    const line = r.content.split('\n').map((s) => s.trim()).find(Boolean) || ''
+    return { ok: true, line, error: '' }
+  }
+
   async _findSecretFile(id) {
     const secretDir = path.join(this.repoPath, '定稿', '设定', '信息差')
     try {

+ 42 - 0
v7/src/util/thread-declarations.js

@@ -0,0 +1,42 @@
+/**
+ * 章 front matter 条目变动声明解析(spec 0.9 §4.1/§5)。机检形式检查与两审 ReviewInput 共用,不双写。
+ */
+
+export const THREAD_TYPES = ['伏笔', '悬念', '感情线']
+
+/** 开启类动词 = 新条目(条目文件由定稿创建) */
+export const OPENING_VERBS = new Set(['埋下', '设下', '开启'])
+
+/** 各类型合法生命周期动词 */
+export const VERBS = {
+  伏笔: ['埋下', '推进', '回收', '放弃'],
+  悬念: ['设下', '推进', '揭晓', '放弃'],
+  感情线: ['开启', '推进', '修成正果', '无疾而终'],
+}
+
+/**
+ * 解析三数组的"动词 ID"声明行。
+ * @param {object} fm 已解析的章 front matter
+ * @returns {{declarations: Array<{type: string, verb: string, id: string, raw: string}>, malformed: string[]}}
+ */
+export function parseThreadDeclarations(fm) {
+  const declarations = []
+  const malformed = []
+  for (const type of THREAD_TYPES) {
+    const list = fm?.[type]
+    if (!Array.isArray(list)) continue
+    for (const raw of list) {
+      if (typeof raw !== 'string') {
+        malformed.push(`${type}: ${JSON.stringify(raw)}`)
+        continue
+      }
+      const m = raw.trim().match(/^(\S+)\s+(\S+)$/)
+      if (!m) {
+        malformed.push(`${type}: ${raw}`)
+        continue
+      }
+      declarations.push({ type, verb: m[1], id: m[2], raw: raw.trim() })
+    }
+  }
+  return { declarations, malformed }
+}

+ 99 - 1
v7/test/mechanical-check/check.test.js

@@ -17,7 +17,7 @@ const 名册 = '| 正名 | 别名 | 类型 | 首现章 |\n|--|--|--|--|\n| 林
 const 信息差 = '---\n读者已知: false\n登记章: 1\n关键词:\n  - 玉佩\n---\n## 内容\n秘密。\n'
 
 // 组装一个含 front matter + 正文的草稿,并放进受控临时仓库
-function files(draftBody, { fm } = {}) {
+function files(draftBody, { fm, extra } = {}) {
   const front =
     fm ??
     `章号: 3\n标题: 测试章\n卷: 1\n字数: ${[...draftBody.replace(/\s+/g, '')].length}\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫`
@@ -27,6 +27,7 @@ function files(draftBody, { fm } = {}) {
     '定稿/设定/名册.md': 名册,
     '定稿/设定/信息差/信息差-001-x.md': 信息差,
     '工作区/草稿-A.md': `---\n${front}\n---\n${draftBody}`,
+    ...extra,
   }
 }
 
@@ -120,3 +121,100 @@ test('机检 信息差关键词命中 → 候选(非阻断)', async () => {
     await cleanup()
   }
 })
+
+// —— 条目变动形式检查(spec 0.9 §8 第 5 步,AC6)——
+
+const 条目 = (状态 = '进行') => `---\n强度: 高\n状态: ${状态}\n开启章: 1\n---\n## 履历\n- 第1章:埋下\n`
+const 条目库 = {
+  '大纲/伏笔/伏笔-001-旧案.md': 条目('进行'),
+  '大纲/伏笔/伏笔-002-旧刀.md': 条目('已收尾'),
+}
+const declFm = (decl) =>
+  `章号: 3\n标题: 测\n卷: 1\n字数: ${[...正常正文.replace(/\s+/g, '')].length}\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫\n${decl}`
+const 条目issues = (r) => r.issues.filter((i) => i.check === '条目变动')
+
+test('机检 条目声明合法(推进进行中 + 埋下新编号)→ 无条目变动 issue', async () => {
+  const { r, cleanup } = await run(正常正文, {
+    fm: declFm('伏笔:\n  - 推进 伏笔-001\n  - 埋下 伏笔-003'),
+    extra: 条目库,
+  })
+  try {
+    assert.equal(r.ok, true)
+    assert.deepEqual(条目issues(r), [], JSON.stringify(r.issues))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('机检 悬念清单混入伏笔编号 → 阻断(类型一致)', async () => {
+  const { r, cleanup } = await run(正常正文, {
+    fm: declFm('悬念:\n  - 推进 伏笔-001'),
+    extra: 条目库,
+  })
+  try {
+    assert.equal(r.pass, false)
+    assert.ok(条目issues(r).some((i) => i.blocking && i.description.includes('异类编号')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('机检 开启类动词撞已有编号 → 阻断', async () => {
+  const { r, cleanup } = await run(正常正文, {
+    fm: declFm('伏笔:\n  - 埋下 伏笔-001'),
+    extra: 条目库,
+  })
+  try {
+    assert.ok(条目issues(r).some((i) => i.blocking && i.description.includes('已存在')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('机检 推进不存在的编号 → 阻断(疑似笔误)', async () => {
+  const { r, cleanup } = await run(正常正文, {
+    fm: declFm('伏笔:\n  - 推进 伏笔-099'),
+    extra: 条目库,
+  })
+  try {
+    assert.ok(条目issues(r).some((i) => i.blocking && i.description.includes('不存在')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('机检 推进已收尾条目 → 阻断(状态不兼容)', async () => {
+  const { r, cleanup } = await run(正常正文, {
+    fm: declFm('伏笔:\n  - 推进 伏笔-002'),
+    extra: 条目库,
+  })
+  try {
+    assert.ok(条目issues(r).some((i) => i.blocking && i.description.includes('已收尾')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('机检 类型不认识的动词 → 阻断(合法动词提示)', async () => {
+  const { r, cleanup } = await run(正常正文, {
+    fm: declFm('伏笔:\n  - 揭晓 伏笔-001'), // 揭晓属悬念,伏笔应为回收
+    extra: 条目库,
+  })
+  try {
+    assert.ok(条目issues(r).some((i) => i.blocking && i.description.includes('合法动词')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('机检 声明行不合「动词 编号」格式 → 阻断', async () => {
+  const { r, cleanup } = await run(正常正文, {
+    fm: declFm('伏笔:\n  - 伏笔-001'),
+    extra: 条目库,
+  })
+  try {
+    assert.ok(条目issues(r).some((i) => i.blocking && i.description.includes('动词 编号')))
+  } finally {
+    await cleanup()
+  }
+})

+ 5 - 1
v7/test/prep/prepare.test.js

@@ -15,7 +15,11 @@ test('prepareChapterMaterials 组装本章写作材料(八组件锚点)并
     assert.match(c, /本章要写到的事/)
     assert.match(c, /查到玉佩/) // 来自细纲
     assert.match(c, /信息差边界/)
-    assert.match(c, /信息差-001/) // 未揭晓信息差,勿泄
+    // 边界行注入 短题/知情人/关键词/内容首句(AC7)——只给编号等于没给
+    assert.match(
+      c,
+      /- 信息差-001(灭门真凶):知情人=林晚;关键词=玉佩\/宗门;内容:玉佩乃前代掌门封印邪灵之物,外人不可知。——读者未知,除知情人的对话与视角外不得出现/
+    )
     assert.match(c, /文风锚点/)
     assert.match(c, /节奏/) // 来自文风铁律
     assert.match(c, /反和解/)

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

@@ -164,3 +164,39 @@ test('P1-3:原始输出与归一化结果分存(.raw.json 保留模型原话
     assert.equal(raw.ai_meta, 'raw-marker', 'raw 保留 AI 额外字段')
   } finally { await cleanup() }
 })
+
+test('拟条目变动进 DTO + 声明条目附履历尾部(未声明者不附)', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n',
+    '定稿/正文/0001-起.md': chapter(1, '过去的事。'),
+    '大纲/伏笔/伏笔-001-旧案.md':
+      '---\n强度: 高\n状态: 进行\n开启章: 1\n---\n## 履历\n- 第1章:埋下——旧案线索(见结尾黑影段)\n- 第2章:推进——查到卷宗\n- 第3章:推进——找到人证\n- 第4章:推进——人证翻供\n',
+    '大纲/悬念/悬念-001-身世.md': '---\n强度: 中\n状态: 进行\n开启章: 1\n---\n## 履历\n- 第1章:设下\n',
+    '工作区/草稿.md':
+      '---\n章号: 5\n标题: 对质\n卷: 1\n字数: 20\n章定位: 推进\n钩子: 危机钩-强\n情绪定位: 铺垫\n伏笔:\n  - 推进 伏笔-001\n  - 埋下 伏笔-002\n---\n林晚当堂对质,人证再度翻供。',
+  })
+  try {
+    const r = await assembleReviewInput(ctx, { chapterNum: 5, draftPath: '工作区/草稿.md' })
+    assert.equal(r.ok, true, r.error)
+    assert.deepEqual(r.input.拟条目变动, [
+      { type: '伏笔', verb: '推进', id: '伏笔-001' },
+      { type: '伏笔', verb: '埋下', id: '伏笔-002' },
+    ])
+    const declared = r.input.相关条目.find((t) => t.id === '伏笔-001')
+    assert.ok(declared, JSON.stringify(r.input.相关条目))
+    assert.equal(declared.履历尾部.length, 3, '履历只取末 3 行')
+    assert.ok(!declared.履历尾部.some((l) => l.includes('第1章')), '开头行不进尾部')
+    assert.match(declared.履历尾部[2], /人证翻供/)
+    const undeclared = r.input.相关条目.find((t) => t.id === '悬念-001')
+    assert.equal(undeclared.履历尾部, undefined, '未声明条目维持纯元数据(控 token)')
+  } finally { await cleanup() }
+})
+
+test('草稿无 front matter → 拟条目变动为空数组(不崩)', async () => {
+  const { ctx, cleanup } = await makeReviewBook()
+  try {
+    const r = await assembleReviewInput(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md' })
+    assert.equal(r.ok, true, r.error)
+    assert.deepEqual(r.input.拟条目变动, [])
+  } finally { await cleanup() }
+})

+ 27 - 0
v7/test/storage/adapters/SecretReader.test.js

@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
 import path from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { SecretReader } from '../../../src/storage/adapters/SecretReader.js'
+import { fixtureCtx } from '../../commands/_helper.js'
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url))
 const fixtureRoot = path.join(__dirname, '../../fixtures/sample-book')
@@ -28,3 +29,29 @@ test('SecretReader.listUnrevealed 无 cache 时返回空数组(不崩)', asy
   const rows = await new SecretReader(fixtureRoot).listUnrevealed()
   assert.deepEqual(rows, [])
 })
+
+test('SecretReader.listUnrevealed 缓存直出 短题/知情人/关键词/登记章', async () => {
+  const { ctx, cleanup } = await fixtureCtx()
+  try {
+    const rows = await new SecretReader(ctx.repoPath, ctx.cache).listUnrevealed()
+    const one = rows.find((s) => s.id === '信息差-001')
+    assert.ok(one, JSON.stringify(rows))
+    assert.equal(one.短题, '灭门真凶') // 文件名第三段起
+    assert.deepEqual(one.知情人, ['林晚'])
+    assert.deepEqual(one.关键词, ['玉佩', '宗门'])
+    assert.equal(one.登记章, 1)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('SecretReader.readContentFirstLine 取「## 内容」段首行', async () => {
+  const r = await new SecretReader(fixtureRoot).readContentFirstLine('信息差-001')
+  assert.equal(r.ok, true)
+  assert.equal(r.line, '玉佩乃前代掌门封印邪灵之物,外人不可知。')
+})
+
+test('SecretReader.readContentFirstLine 不存在 → ok=false', async () => {
+  const r = await new SecretReader(fixtureRoot).readContentFirstLine('信息差-999')
+  assert.equal(r.ok, false)
+})