Sfoglia il codice sorgente

docs(task): 登记 M1 任务规划 + review 后修正三件套

此前 M1 planning 工件一直未提交(git 里是 untracked)。本次登记并修正
review 发现的计划缺陷:

- design §6.2 命令契约自相矛盾(既 return 又 console.log+process.exit)
  → 改为 run(args,options,ctx) 纯返回 {ok,output,error},bin 唯一负责副作用,
    命令方可单测(AC2 前提)
- design §6.1 命令清单漏 read-worldview、read-chapters → 补全,标注 21 文件载 41 接口
- design §6.3 listOverdue 英文 type 拼中文 key 永不匹配 → 加 TYPE_CN 映射
- design §5.3 scanCharacters 只 UPDATE 丢角色卡数据 → 注明必须 upsert
- design §5 「五表」实为六张物理表 → 术语澄清
- design §7.1 fixture 缺 总纲/卷纲/世界观/第二章摘要 → 补足 P2 可测
- prd AC2 嵌入权威 41 接口逐条清单(对齐 O4 §2),使「逐条测试」可验收
- implement.md 增 R0-R5 重建计划(覆盖被 revert 的空壳 D.2+D.3)
lingfengQAQ 2 giorni fa
parent
commit
2c34fbe9ba

+ 4 - 0
.trellis/tasks/06-27-m1-format-core/check.jsonl

@@ -0,0 +1,4 @@
+{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "评审清单 §5(零依赖/术语表/错误处理/职责分界/文档先行)"}
+{"file": "docs/architecture/story-repo-spec-2026-06-10.md", "reason": "不变量 2(删缓存可重建)、不变量 9(保留未知字段)、§2.3 防呆方言"}
+{"file": "docs/architecture/cache-design-2026-06-26.md", "reason": "§7 实施检查清单(五表 DDL 可执行、41 接口完整、重建器逻辑明确)"}
+{"file": ".trellis/tasks/06-27-m1-format-core/prd.md", "reason": "验收标准 AC1-AC12(删缓存重建、41 接口测试、容错读取、防呆写出、Windows 中文路径、小端口分离)"}

+ 1020 - 0
.trellis/tasks/06-27-m1-format-core/design.md

@@ -0,0 +1,1020 @@
+# 技术设计:M1 格式层核心库 + 派生缓存
+
+## 1. 设计范围
+
+四层能力(依赖链):
+1. **容错读写库**:解析/序列化 front matter、平铺 YAML、Markdown 表格、条目文件
+2. **Storage Adapter 小端口**:按职责拆分的 8+ 独立 reader/writer 端口(ChapterReader、ThreadLedgerReader 等)
+3. **`.cache/index.db` 五表 + 重建器**:node:sqlite,只读源文件全量重建,任何时刻可删
+4. **41 精准读取接口 CLI**:分 P0/P1/P2 三层实现,每层一 commit 检查点
+
+下游 M2+ 在这四层地基上长真实业务逻辑(写章流程、状态机、AI 角色)。
+
+## 2. 架构分层与数据流
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│  CLI 41 接口层(P0/P1/P2)                                    │
+│  read-chapter / list-threads / report-overdue-threads ...   │
+└────────────┬────────────────────────────────────────────────┘
+             │
+             v
+┌─────────────────────────────────────────────────────────────┐
+│  Storage Adapter 小端口                                       │
+│  ChapterReader / ThreadLedgerReader / EntityReader ...       │
+│  (依赖缓存或直接读文件,调用方无感知)                        │
+└────────────┬────────────────────────────────────────────────┘
+             │
+             v
+┌──────────────────┐        ┌─────────────────────────────────┐
+│  .cache/index.db │  ◄───  │  容错读写库(parser/serializer) │
+│  五表 + 索引      │        │  front matter / YAML / Markdown │
+└──────────────────┘        └─────────────────────────────────┘
+      ▲                                ▲
+      │ 重建器                          │
+      │                                │
+      └────────────────────────────────┘
+               只读 定稿/大纲/文风 源文件
+```
+
+**关键原则**(spec §1.5):
+- 上层不知道下层用了缓存还是直接读文件
+- Storage Adapter 是稳定接口,缓存策略变更不影响调用方
+- 重建器只读源文件,不读工作区
+
+## 3. 层 1:容错读写库
+
+### 3.1 模块结构
+
+```
+v7/src/storage/parsers/
+├── front-matter.js      # 分离 YAML front matter 与 Markdown 正文
+├── yaml-safe.js         # 容错 YAML 解析(保留未知字段)
+├── markdown-table.js    # 解析 Markdown 表格(时间线、名册)
+└── book-config.js       # book.yaml 平铺字段提取
+
+v7/src/storage/serializers/
+├── yaml-dialect.js      # 防呆方言:平铺/块列表/危险值引号
+└── front-matter.js      # 组装 front matter + 正文
+```
+
+### 3.2 Front Matter 解析契约
+
+```js
+// front-matter.js
+export function parseFrontMatter(content) {
+  // 输入:完整文件内容(含 --- 包裹的 YAML + Markdown 正文)
+  // 输出:{ ok: boolean, data: object | null, body: string, error: string }
+  // ok=true: data 是解析后对象,body 是去掉 front matter 的 Markdown 正文
+  // ok=false: error 含错误描述(中文),data=null
+}
+```
+
+**容错规则**:
+- `---` 不存在或不配对:返回 `{ok: false, error: "缺少 front matter 分隔符"}`
+- YAML 语法错误:返回 `{ok: false, error: "YAML 解析失败:<原因>"}`
+- **不崩溃**:任何输入都返回对象,不抛异常
+
+### 3.3 YAML 安全解析
+
+```js
+// yaml-safe.js
+export function parseYAML(yamlString, options = {}) {
+  // options.preserveUnknown: true(默认)保留未知字段
+  // 返回:{ ok: boolean, data: object | null, error: string, raw: string }
+  // raw: 原始 YAML 字符串(写回时用)
+}
+```
+
+**保留未知字段策略**(不变量 9):
+- 解析时记录原始 YAML 字符串
+- 写回时:已知字段用新值,未知字段从原始串提取后拼接
+
+### 3.4 防呆序列化
+
+```js
+// yaml-dialect.js
+export function serializeYAML(data, options = {}) {
+  // 输入:JS 对象(平铺,不含嵌套映射)
+  // 输出:符合防呆方言的 YAML 字符串
+  // 规则:
+  // 1. 一律平铺(检测嵌套抛错)
+  // 2. 数组输出块格式:\n- item1\n- item2
+  // 3. 危险值加引号:数字串/true/false/null/含冒号
+  // 4. 两空格缩进
+}
+
+function needsQuoting(value) {
+  // 判断是否需要引号:
+  // - 纯数字字符串:"123" → "\"123\""
+  // - 布尔/null 字面值:"true" → "\"true\""
+  // - 含冒号/特殊字符:"A:B" → "\"A:B\""
+}
+```
+
+### 3.5 Markdown 表格解析
+
+```js
+// markdown-table.js
+export function parseMarkdownTable(content) {
+  // 输入:Markdown 表格文本(含表头 | A | B | C |)
+  // 输出:{ ok: boolean, headers: string[], rows: object[], error: string }
+  // rows: [{A: "值1", B: "值2"}, ...]
+  // 容错:表头不对齐、空行跳过、不崩溃
+}
+```
+
+**用途**:
+- 时间线:`| 章 | 书内时间 | 一句话事件 | 在场 |`
+- 名册:`| 正名 | 别名 | 类型 | 首现章 |`
+
+## 4. 层 2:Storage Adapter 小端口
+
+### 4.1 端口清单(拒绝上帝对象)
+
+```
+v7/src/storage/
+├── adapters/
+│   ├── ChapterReader.js         # 读章节 front matter、正文、范围
+│   ├── ChapterWriter.js         # 写新章到定稿(M2 落地,本任务只定接口占位)
+│   ├── ThreadLedgerReader.js    # 读三类条目(伏笔/悬念/感情线)
+│   ├── ThreadLedgerWriter.js    # 更新条目、追加履历(M2 落地,占位)
+│   ├── EntityReader.js          # 读角色卡、解析别名
+│   ├── TimelineReader.js        # 读时间线、按在场过滤
+│   ├── SecretReader.js          # 读信息差
+│   ├── OutlineReader.js         # 读总纲/卷纲
+│   └── BookConfigReader.js      # 读 book.yaml
+└── index.js                     # 统一导出(按需 import)
+```
+
+### 4.2 ChapterReader 接口
+
+```js
+// ChapterReader.js
+export class ChapterReader {
+  constructor(repoPath, cache) {
+    // repoPath: 书仓库根目录
+    // cache: CacheManager 实例(可选,无缓存时直接读文件)
+  }
+
+  async readFrontMatter(chapterNum) {
+    // 返回:{ ok, data: {章号, 标题, 卷, 视角, ...}, error }
+    // 策略:优先查缓存 chapters 表,缺失时读文件
+  }
+
+  async readBody(chapterNum) {
+    // 返回:{ ok, body: string, error }
+    // 纯正文(不含 front matter)
+  }
+
+  async readTail(chapterNum, wordCount) {
+    // 返回:{ ok, text: string, error }
+    // 正文末尾 N 字
+  }
+
+  async readHead(chapterNum, wordCount) {
+    // 正文开头 N 字
+  }
+
+  async readRange(startChapter, endChapter, fields = ['摘要']) {
+    // 批量读取章号范围,指定字段
+    // 返回:{ ok, chapters: [{章号, ...fields}], error }
+  }
+}
+```
+
+### 4.3 ThreadLedgerReader 接口
+
+```js
+// ThreadLedgerReader.js
+export class ThreadLedgerReader {
+  constructor(repoPath, cache) {}
+
+  async readBasicInfo(threadId) {
+    // threadId: "伏笔-031" / "悬念-008" / "感情线-012"
+    // 返回:{ ok, data: {强度, 状态, 开启章, 预计收尾, 最后推进章}, error }
+  }
+
+  async readHistory(threadId) {
+    // 返回:{ ok, history: [{章号, 动作, 描述, 证据}], error }
+    // 从履历段落解析(第N章:动作——描述(见...))
+  }
+
+  async readClosurePlan(threadId) {
+    // 返回:{ ok, plan: string, error }
+    // 从 "## 收尾计划" 段落提取
+  }
+
+  async readDescription(threadId) {
+    // 从 "## 描述" 段落提取
+  }
+
+  async listOverdue(bookConfig) {
+    // 输入:book.yaml 配置(各类型悬了太久章数阈值)
+    // 返回:[{id, type, 悬了多少章, 最后推进章}]
+    // 计算:当前最大章号 − last_advanced_chapter > 阈值
+  }
+
+  async listByType(type, status = null) {
+    // type: "foreshadow" / "suspense" / "romance"
+    // status: "进行" / "已收尾" / "已放弃"(可选)
+    // 返回:[{id, short_title, strength, status}]
+  }
+}
+```
+
+### 4.4 EntityReader 接口
+
+```js
+// EntityReader.js
+export class EntityReader {
+  constructor(repoPath, cache) {}
+
+  async readCharacterFrontMatter(name) {
+    // name: 正名
+    // 返回:{ ok, data: {姓名, 境界, 位置, 状态, 持有, 最后变更章}, error }
+  }
+
+  async readCharacterFull(name) {
+    // 返回:{ ok, frontMatter, body: string, error }
+    // body 是设定/典型对话/关系段落
+  }
+
+  async resolveAlias(alias) {
+    // 返回:{ ok, canonicalName: string | null, error }
+    // 查 entity_aliases 表或解析名册文件
+  }
+
+  async listCharacters(filter = {}) {
+    // filter: { status: "在世" } 等
+    // 返回:[{正名, 状态, 位置, 境界}]
+  }
+}
+```
+
+### 4.5 其他端口(简要)
+
+**TimelineReader**:`readCurrentVolume()` / `readVolumeRange(start, end)` / `readByParticipant(name)`
+
+**SecretReader**:`readBasicInfo(id)` / `readContent(id)` / `listUnrevealed()`
+
+**OutlineReader**:`readOutlineSection(type, volumeNum?, sectionTitle?)` / `listVolumes()`
+
+**BookConfigReader**:`read()` 返回 book.yaml 平铺对象
+
+## 5. 层 3:`.cache/index.db` 缓存表与重建器
+
+> 术语澄清:习称「五表」指五张主表(`chapters` / `threads` / `secrets` / `entities` / `fingerprints`),另有 `entity_aliases` 别名关联表挂在 `entities` 下,**物理上共 6 张 CREATE TABLE**。下文 DDL 与 schema.js 以 6 张为准。
+
+### 5.1 表结构(O4 §1 DDL,精简版)
+
+```sql
+-- chapters 表
+CREATE TABLE chapters (
+  chapter_num INTEGER PRIMARY KEY,
+  title TEXT NOT NULL,
+  volume_num INTEGER NOT NULL,
+  perspective TEXT,
+  story_time TEXT,
+  word_count INTEGER NOT NULL,
+  chapter_position TEXT NOT NULL,  -- 推进|过渡|日常
+  hook_type TEXT,                  -- 危机钩-强 等
+  mood_position TEXT,              -- 压抑|铺垫|小爽|大爽|转折
+  file_path TEXT NOT NULL,
+  is_key_chapter BOOLEAN DEFAULT 0
+);
+CREATE INDEX idx_chapters_volume ON chapters(volume_num);
+CREATE INDEX idx_chapters_position ON chapters(chapter_position);
+CREATE INDEX idx_chapters_hook ON chapters(hook_type);
+
+-- threads 表(三类条目统一)
+CREATE TABLE threads (
+  id TEXT PRIMARY KEY,             -- 伏笔-031、悬念-008、感情线-012
+  type TEXT NOT NULL,              -- foreshadow | suspense | romance
+  short_title TEXT NOT NULL,
+  strength TEXT NOT NULL,          -- 高|中|低
+  status TEXT NOT NULL,            -- 进行|已收尾|已放弃
+  opened_chapter INTEGER NOT NULL,
+  planned_end TEXT,                -- 第7卷 或 章号
+  last_advanced_chapter INTEGER,
+  file_path TEXT NOT NULL
+);
+CREATE INDEX idx_threads_type ON threads(type);
+CREATE INDEX idx_threads_status ON threads(status);
+
+-- secrets 表
+CREATE TABLE secrets (
+  id TEXT PRIMARY KEY,
+  short_title TEXT NOT NULL,
+  known_to TEXT NOT NULL,          -- JSON array
+  reader_knows BOOLEAN NOT NULL,
+  registered_chapter INTEGER NOT NULL,
+  keywords TEXT NOT NULL,          -- JSON array
+  file_path TEXT NOT NULL
+);
+CREATE INDEX idx_secrets_reader_knows ON secrets(reader_knows);
+
+-- entities 表
+CREATE TABLE entities (
+  id TEXT PRIMARY KEY,             -- 正名
+  type TEXT NOT NULL,              -- character | location | item | faction
+  status TEXT,
+  location TEXT,
+  realm TEXT,
+  possessions TEXT,                -- JSON array
+  last_changed_chapter INTEGER,
+  file_path TEXT NOT NULL
+);
+CREATE INDEX idx_entities_type ON entities(type);
+CREATE INDEX idx_entities_status ON entities(status);
+
+-- entity_aliases 表(别名 → 正名,唯一真相源)
+CREATE TABLE entity_aliases (
+  alias TEXT PRIMARY KEY,
+  entity_id TEXT NOT NULL,
+  FOREIGN KEY(entity_id) REFERENCES entities(id)
+);
+CREATE INDEX idx_entity_aliases_entity ON entity_aliases(entity_id);
+
+-- fingerprints 表
+CREATE TABLE fingerprints (
+  chapter_range_start INTEGER NOT NULL,
+  chapter_range_end INTEGER NOT NULL,
+  is_baseline BOOLEAN DEFAULT 0,
+  avg_sentence_length REAL,
+  sentence_length_variance REAL,
+  avg_paragraph_length REAL,
+  common_phrase_frequency TEXT,   -- JSON object
+  vocabulary_richness REAL,
+  fingerprint_data TEXT NOT NULL,  -- 完整指纹 JSON
+  PRIMARY KEY(chapter_range_start, chapter_range_end)
+);
+CREATE INDEX idx_fingerprints_baseline ON fingerprints(is_baseline);
+```
+
+### 5.2 CacheManager 接口
+
+```js
+// v7/src/cache/index.js
+export class CacheManager {
+  constructor(dbPath) {
+    // dbPath: 书仓库/.cache/index.db
+    // 使用 node:sqlite (Node ≥ 22.13.0 内置)
+  }
+
+  async ensureReady(repoPath) {
+    // 检查 index.db 是否存在且可用
+    // 不存在或损坏 → 调用 rebuildFromSource(repoPath)
+  }
+
+  async rebuildFromSource(repoPath) {
+    // 全量重建:DELETE 五表 → 扫描源文件 → INSERT
+    // 只读 定稿/、大纲/、文风/
+    // 返回:{ ok, warnings: [], errors: [] }
+    // warnings: 履历引用不存在的章节文件
+    // errors: 别名冲突(致命,拒绝重建)
+  }
+
+  async query(sql, params) {
+    // 执行 SELECT 查询,返回结果集
+  }
+
+  async close() {
+    // 关闭数据库连接
+  }
+}
+```
+
+### 5.3 重建器逻辑
+
+```js
+// v7/src/cache/rebuilder.js
+export async function rebuildCache(repoPath, db) {
+  // 步骤:
+  // 1. 清空五表(DELETE)
+  // 2. 扫描 定稿/正文/*.md → 填充 chapters 表
+  // 3. 扫描 大纲/伏笔|悬念|感情线/*.md → 填充 threads 表
+  // 4. 扫描 定稿/设定/信息差/*.md → 填充 secrets 表
+  // 5. 解析 定稿/设定/名册.md → 填充 entities + entity_aliases 表
+  // 6. 扫描 定稿/设定/角色/*.md → upsert entities 表(type=character)
+  //    注意:必须 INSERT-or-UPDATE。角色卡可能不在名册里(名册非强制全覆盖),
+  //    只 UPDATE 会丢掉这些角色 → 用 INSERT ... ON CONFLICT(id) DO UPDATE
+  // 7. fingerprints 表留空(特征提取随 M3+ 体检补)
+  // 8. 校验:
+  //    - 履历引用章节文件是否存在(警告)
+  //    - 别名唯一性(错误)
+  // 9. 返回 warnings 和 errors
+}
+
+async function scanChapters(repoPath) {
+  // 扫描 定稿/正文/*.md
+  // 返回:Map<章号, 文件路径>
+  // 用途:履历验证时查映射(避免 fs.access 通配符问题)
+}
+```
+
+**校验规则**(O4 §4.3):
+- **履历证据章节验证**(spec 0.8 A3):
+  - 步骤:
+    1. `scanChapters(repoPath)` 返回章号 → 文件路径映射(Map)
+    2. 解析条目履历行的"第N章",提取章号
+    3. 查 Map,章号不存在 → 记 warning(不阻断重建)
+  - 格式:`第152章:推进——林晚取得实证(见本章结尾对峙段)`
+  - 验证范围:章节文件存在性(脚本能做的),不做语义验证(AI 两审负责)
+- **别名唯一性**:同一 alias 指向多个 entity_id → 记 error,拒绝重建
+
+### 5.4 派生值策略(不物化)
+
+**查询时计算**(O4 §0 原则 5):
+- `悬了太久章数 = MAX(chapter_num) FROM chapters − last_advanced_chapter`
+- `信息差蓄积章数 = MAX(chapter_num) − registered_chapter`
+- `是否超期 = 悬了太久章数 > book.yaml 阈值`
+
+**不写入表**:避免每章定稿刷新全表,避免缓存与真相脱节。
+
+## 6. 层 4:41 精准读取接口 CLI
+
+### 6.1 命令组织
+
+```
+v7/bin/webnovel-writer.js  ← 入口,动态 import 分发(按命令名加载 src/commands/${命令}.js)
+v7/src/commands/            ← 命令模块目录(21 个文件承载 O4 §2 的 41 个接口,多数接口是同一文件的 --选项)
+├── read-chapter.js         # 5 接口:<num> | --front-matter | --tail=N | --head=N | --摘要
+├── read-chapters.js        # 2 接口:--range=<a>-<b> --摘要 | --recent=<N> --tail=<M>(复数,批量)
+├── list-chapters.js        # 1 接口:--章定位=推进 [--卷=N]
+├── read-thread.js          # 4 接口:--fields=基本信息 | --履历 | --收尾计划 | --描述
+├── list-threads.js         # 3 接口:--悬了太久 | --type=<t> [--status=<s>] | --strength=<强>
+├── read-timeline.js        # 4 接口:--current-volume | --current-and-prev | --卷=N | --在场=<名>
+├── read-character.js       # 3 接口:<name> | --front-matter | --section=<标题>
+├── resolve-alias.js        # 1 接口:<别名>
+├── list-characters.js      # 1 接口:[--status=<状态>]
+├── read-worldview.js       # 1 接口:--section=<标题>(读 定稿/设定/世界观.md)
+├── read-secret.js          # 2 接口:--基本信息 | --内容
+├── list-secrets.js         # 1 接口:--reader-knows=false
+├── read-outline.js         # 4 接口:--总纲 --section=<标题> | --总纲 --结局 | --卷=N | --卷=N --section
+├── list-volumes.js         # 1 接口:列出所有卷纲
+├── grep-story.js           # 2 接口:<关键词> | --regex=<pattern>
+├── report-overdue-threads.js       # 1 接口
+├── report-secret-accumulation.js   # 1 接口
+├── report-weak-hook-streak.js      # 1 接口
+├── report-thread-activity.js       # 1 接口:--卷=N
+├── report-book-stats.js            # 1 接口
+└── report-style-drift.js   # 1 接口:能读指纹表并对比基线,但 M1 不做特征提取(表留空 → 返回友好错误)
+```
+
+> 接口总数核对(对齐 O4 §2.2-2.9):条目 7 + 大纲 5 + 正文 8 + 时间线 4 + 设定 6 + 信息差 3 + 全文检索 2 + 报表 6 = **41**。权威逐条清单见 prd.md AC2。
+
+**动态分发逻辑**(bin/webnovel-writer.js):
+```js
+const command = process.argv[2]
+if (!command || command === '--help') {
+  console.log('用法:webnovel-writer <命令> [选项]')
+  process.exit(0)
+}
+
+let cache
+try {
+  const mod = await import(`../src/commands/${command}.js`)
+  const { positionalArgs, options } = parseArgs(process.argv.slice(3))
+
+  const repoPath = process.cwd() // M3 状态机后续会处理工作目录定位
+  cache = new CacheManager(path.join(repoPath, '.cache', 'index.db'))
+  await cache.ensureReady(repoPath)
+
+  const result = await mod.run(positionalArgs, options, { repoPath, cache })
+  if (result.ok) {
+    if (result.output) console.log(result.output)
+    process.exitCode = 0
+  } else {
+    console.error(result.error)
+    process.exitCode = 1
+  }
+} catch (err) {
+  if (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'ENOENT') {
+    console.error(`未知命令「${command}」。运行 webnovel-writer --help 查看可用命令。`)
+    process.exitCode = 1
+  } else {
+    console.error(`执行命令「${command}」时出错:${err.message}`) // 永不带栈崩
+    process.exitCode = 1
+  }
+} finally {
+  if (cache) await cache.close() // 显式关闭,避免 Windows 文件锁
+}
+```
+
+**分层实现**(每层一 sub-commit):
+- **P0(8 个)**:read-timeline (current-and-prev)、read-thread (基本+履历)、read-chapter (tail+摘要)、read-character (front-matter)、resolve-alias、report-overdue-threads
+- **P1(7 个)**:read-chapter (front-matter+按章定位筛选)、list-secrets (未揭晓)、grep-story (关键词)、report-book-stats、report-weak-hook-streak
+- **P2(26 个)**:其余全部
+
+### 6.2 命令范式(可测契约)
+
+**契约**:每个命令模块导出 `run(args, options, ctx)`,**只返回结果对象,不做 IO 副作用**(命令内部不 `console.log`、不 `process.exit`、不 `new CacheManager`)。打印、退出码、缓存生命周期由 bin 入口统一处理。命令因此可被单元测试直接调用(喂 fixture `repoPath` + 临时 `cache`,断言返回对象),这是 AC2「41 接口逐条测试」能成立的前提。
+
+> 历史教训:早期版本让 `execute` 同时 `return` 又 `console.log + process.exit`,契约自相矛盾,导致命令层无法单测、35 个接口被写成空壳。本契约统一为"纯函数返回 + bin 副作用"。
+
+```js
+// 命令契约
+// @param {string[]} args     位置参数(已剥离选项)
+// @param {object}   options  解析后的 --选项(--key=value → {key:value},--flag → {flag:true})
+// @param {{repoPath: string, cache: CacheManager}} ctx  注入的运行上下文
+// @returns {Promise<{ok: boolean, output?: string, error?: string}>}
+//   ok=true  → output 是要打印到 stdout 的字符串(JSON.stringify 或纯文本)
+//   ok=false → error 是要打印到 stderr 的中文错误;bin 以 exit(1) 退出
+
+// read-chapter.js 示例
+import { ChapterReader } from '../storage/adapters/ChapterReader.js'
+
+export async function run(args, options, ctx) {
+  const chapterNum = parseInt(args[0], 10)
+  if (isNaN(chapterNum)) {
+    return { ok: false, error: '章号必须是数字' }
+  }
+
+  const reader = new ChapterReader(ctx.repoPath, ctx.cache)
+
+  if (options['front-matter']) {
+    const r = await reader.readFrontMatter(chapterNum)
+    return r.ok
+      ? { ok: true, output: JSON.stringify(r.data, null, 2) }
+      : { ok: false, error: r.error }
+  }
+  // ... 其他分支同理:拿 adapter 结果 → 映射成 {ok, output|error}
+}
+```
+
+**bin/webnovel-writer.js 是唯一副作用点**(打印 / 退出 / 缓存生命周期):
+```js
+const cache = new CacheManager(path.join(repoPath, '.cache', 'index.db'))
+await cache.ensureReady(repoPath)
+try {
+  const result = await mod.run(positionalArgs, options, { repoPath, cache })
+  if (result.ok) {
+    if (result.output) console.log(result.output)
+    process.exitCode = 0
+  } else {
+    console.error(result.error)
+    process.exitCode = 1
+  }
+} finally {
+  await cache.close()  // 显式关闭,避免 Windows 文件锁
+}
+```
+
+**统一输出格式**:
+- 成功:`{ok: true, output}` → bin 打印 output 到 stdout,exit 0
+- 失败:`{ok: false, error}` → bin 打印 error 到 stderr,exit 1
+- 命令内部**不碰** `process` / `console` / 文件锁,保证可单测
+
+### 6.3 特殊接口设计
+
+**report-overdue-threads**:
+```js
+// 查询时计算悬了太久章数
+const maxChapter = await db.query('SELECT MAX(chapter_num) FROM chapters')
+const overdueThreads = await db.query(`
+  SELECT id, type, short_title, last_advanced_chapter,
+         (? - last_advanced_chapter) as overdue_count
+  FROM threads
+  WHERE status = '进行'
+`, [maxChapter])
+
+// 按 book.yaml 阈值过滤
+const config = await bookConfigReader.read()
+const TYPE_CN = { foreshadow: '伏笔', suspense: '悬念', romance: '感情线' }
+const filtered = overdueThreads.filter(t => {
+  // book.yaml 的 key 是中文「伏笔悬了太久章数」,threads.type 是英文,必须先映射
+  const threshold = config.data[`${TYPE_CN[t.type]}悬了太久章数`] ?? 10
+  return t.overdue_count > threshold
+})
+
+// 按类型分组输出
+return groupBy(filtered, 'type')
+```
+
+**report-weak-hook-streak**:
+```js
+// 从 chapters 表倒序查连续弱钩
+const recentChapters = await db.query(`
+  SELECT chapter_num, hook_type
+  FROM chapters
+  ORDER BY chapter_num DESC
+  LIMIT 20
+`)
+
+let streak = 0
+for (const ch of recentChapters) {
+  // spec §4.1: 钩子值形如"危机钩-强"、"危机钩-弱"、"情绪钩-弱"
+  if (ch.hook_type?.includes('弱钩') || ch.hook_type?.endsWith('-弱')) {
+    streak++
+  } else {
+    break
+  }
+}
+
+return { streak }
+```
+
+**report-style-drift**(M1 边界):
+```js
+// 读基线指纹(is_baseline=1)
+const baseline = await db.query(`
+  SELECT * FROM fingerprints WHERE is_baseline = 1
+`)
+
+// 读最近章段指纹(假设已有)
+const recent = await db.query(`
+  SELECT * FROM fingerprints
+  ORDER BY chapter_range_end DESC
+  LIMIT 1
+`)
+
+if (!baseline || !recent) {
+  return { ok: false, error: '缺少指纹数据。fingerprints 表为空,请先运行体检以提取特征。' }
+}
+
+// 对比特征(简单差值)
+const drift = {
+  avg_sentence_length_delta: recent.avg_sentence_length - baseline.avg_sentence_length,
+  // ... 其他特征
+}
+
+return { baseline, recent, drift }
+```
+
+**M1 边界说明**:`report-style-drift` 接口存在、能读 `fingerprints` 表并对比基线,但 M1 **不实现特征提取函数**。测试策略:手工插入基线数据 + 最近数据到 `fingerprints` 表,测试对比逻辑;真实特征提取(avg_sentence_length 计算等)留 M3+ 体检/卷复盘实现。
+
+## 7. 测试策略
+
+### 7.1 测试结构(镜像 src)
+
+```
+v7/test/
+├── storage/
+│   ├── parsers/
+│   │   ├── front-matter.test.js
+│   │   ├── yaml-safe.test.js
+│   │   └── markdown-table.test.js
+│   ├── serializers/
+│   │   └── yaml-dialect.test.js
+│   └── adapters/
+│       ├── ChapterReader.test.js
+│       ├── ThreadLedgerReader.test.js
+│       └── EntityReader.test.js
+├── cache/
+│   ├── CacheManager.test.js
+│   └── rebuilder.test.js
+├── commands/
+│   ├── read-chapter.test.js
+│   ├── list-threads.test.js
+│   └── ... (41 个接口各一测试)
+└── fixtures/
+    └── sample-book/          # 示例书仓库(定稿/大纲/文风完整结构)
+        ├── book.yaml
+        ├── 定稿/
+        │   ├── 正文/
+        │   │   ├── 0001-开局.md
+        │   │   └── 0002-初遇.md
+        │   ├── 设定/
+        │   │   ├── 角色/林晚.md
+        │   │   ├── 信息差/信息差-001-灭门真凶.md
+        │   │   ├── 时间线/第01卷.md
+        │   │   ├── 世界观.md          # read-worldview --section 用
+        │   │   └── 名册.md
+        │   └── 摘要/
+        │       └── 章摘要/
+        │           ├── 0001.md
+        │           └── 0002.md        # read-chapters --range 摘要用
+        └── 大纲/
+            ├── 总纲.md                # read-outline --总纲 [--section|--结局] 用
+            ├── 第01卷.md              # read-outline --卷 / list-volumes 用
+            ├── 伏笔/伏笔-001-神秘老者.md
+            ├── 悬念/悬念-001-玉佩来历.md   # list-threads --type=suspense 用
+            └── 感情线/感情线-001-大师兄.md # list-threads --type=romance 用
+```
+
+> 扩充说明:原 fixture 只够测 P0/P1。P2 的 read-outline / list-volumes / read-worldview 需要 `大纲/总纲.md`、`大纲/第NN卷.md`、`定稿/设定/世界观.md`;list-threads 按类型筛选需要悬念/感情线各一条;read-chapters 范围摘要需要 ≥2 个章摘要。这些在「阶段 D 重建」前补齐。
+
+**Fixture 完整示例**:
+
+`0001-开局.md`(章节文件):
+```markdown
+---
+章号: 1
+标题: 开局
+卷: 1
+视角: 林晚
+书内时间: 大历1023年春月初一
+字数: 2800
+章定位: 推进
+钩子: 危机钩-强
+情绪定位: 铺垫
+伏笔:
+  - 埋下 伏笔-001
+本章要写到的事:
+  - 林晚初入宗门
+  - 神秘老者出现
+---
+
+林晚抬头望着宗门大殿,心中忐忑不安。
+
+(正文约 2800 字...)
+
+结尾处,一道黑影闪过,留下一枚玉佩。
+```
+
+`伏笔-001-神秘老者.md`(条目文件):
+```markdown
+---
+强度: 高
+状态: 进行
+开启章: 1
+预计收尾: 第3卷
+最后推进章: 1
+---
+## 描述
+神秘老者的真实身份。
+
+## 收尾计划
+第三卷揭晓:曾是宗门长老,因禁术被逐。
+
+## 履历
+- 第1章:埋下——神秘老者留玉佩(见本章结尾黑影段落)
+```
+
+`林晚.md`(角色卡):
+```markdown
+---
+姓名: 林晚
+别名:
+  - 晚晚
+  - 林师妹
+状态: 在世
+位置: 青云宗
+境界: 练气三层
+持有:
+  - 青霜剑
+最后变更章: 1
+---
+## 设定
+外门弟子,天赋中等但心性坚韧。
+
+## 典型对话
+"本姑娘才不怕!"
+
+## 关系
+与大师兄关系微妙。
+```
+
+`第01卷.md`(时间线):
+```markdown
+| 章 | 书内时间 | 一句话事件 | 在场 |
+|----|----------|------------|------|
+| 1 | 1023春月初一 | 林晚入宗门,神秘老者留玉佩 | 林晚 |
+```
+
+`名册.md`:
+```markdown
+| 正名 | 别名 | 类型 | 首现章 |
+|------|------|------|---------|
+| 林晚 | 晚晚, 林师妹 | character | 1 |
+| 神秘老者 | 黑衣人 | character | 1 |
+```
+
+`book.yaml`:
+```yaml
+spec_version: "7.0"
+书名: 测试书
+类型: 玄幻
+每章目标字数: 3000
+卷规模: 40
+文体基线起: 1
+文体基线止: 30
+伏笔悬了太久章数: 10
+悬念悬了太久章数: 10
+感情线悬了太久章数: 30
+连续弱钩上限: 3
+关键章稿数: 3
+自动确认细纲: false
+连写批次大小: 8
+```
+
+### 7.2 关键测试用例
+
+**AC1 删光 `.cache` 全量重建**
+```js
+// test/cache/rebuilder.test.js
+test('删除缓存后全量重建,查询结果不变', async () => {
+  const repoPath = 'test/fixtures/sample-book'
+  const dbPath = `${repoPath}/.cache/index.db`
+  
+  // 第一次重建
+  const cache1 = new CacheManager(dbPath)
+  await cache1.ensureReady(repoPath)
+  const result1 = await cache1.query('SELECT COUNT(*) as count FROM chapters')
+  
+  // 删除缓存
+  await fs.rm(dbPath, { force: true })
+  
+  // 第二次重建
+  const cache2 = new CacheManager(dbPath)
+  await cache2.ensureReady(repoPath)
+  const result2 = await cache2.query('SELECT COUNT(*) as count FROM chapters')
+  
+  assert.equal(result1[0].count, result2[0].count)
+})
+```
+
+**AC3 容错读取保留未知字段**
+```js
+// test/storage/parsers/yaml-safe.test.js
+test('未知字段保留并原样写回', async () => {
+  const original = `---
+章号: 1
+标题: 测试
+自定义字段: 自定义值
+---
+正文`
+  
+  const parsed = parseFrontMatter(original)
+  assert.equal(parsed.ok, true)
+  assert.equal(parsed.data.自定义字段, '自定义值')
+  
+  // 修改已知字段
+  parsed.data.标题 = '新标题'
+  
+  // 写回
+  const serialized = serializeFrontMatter(parsed.data, parsed.body)
+  assert.ok(serialized.includes('自定义字段: 自定义值'))
+  assert.ok(serialized.includes('标题: 新标题'))
+})
+```
+
+**AC4 防呆写出**
+```js
+// test/storage/serializers/yaml-dialect.test.js
+test('列表输出块格式', () => {
+  const data = { 伏笔: ['伏笔-001', '伏笔-002'] }
+  const yaml = serializeYAML(data)
+  assert.ok(yaml.includes('伏笔:\n  - 伏笔-001\n  - 伏笔-002'))
+  assert.ok(!yaml.includes('[伏笔-001, 伏笔-002]'))
+})
+
+test('危险值加引号', () => {
+  const data = { 章号: '123', 开关: 'true', 标题: '包含:冒号' }
+  const yaml = serializeYAML(data)
+  assert.ok(yaml.includes('章号: "123"'))
+  assert.ok(yaml.includes('开关: "true"'))
+  assert.ok(yaml.includes('标题: "包含:冒号"'))
+})
+```
+
+**AC5 Windows 中文路径**
+```js
+// test/integration/chinese-path.test.js
+test('Windows 中文路径全链路', async (t) => {
+  if (process.platform !== 'win32') {
+    t.skip('Windows 专用测试')
+    return
+  }
+  
+  const tmpDir = path.join(os.tmpdir(), '测试书仓库')
+  // 构建含中文目录/文件的 fixture
+  await setupChinesePathFixture(tmpDir)
+  
+  // 重建缓存
+  const cache = new CacheManager(`${tmpDir}/.cache/index.db`)
+  await cache.ensureReady(tmpDir)
+  
+  // 读取接口
+  const reader = new ChapterReader(tmpDir, cache)
+  const result = await reader.readFrontMatter(1)
+  
+  assert.equal(result.ok, true)
+  assert.equal(result.data.标题, '测试章节')
+  
+  // 显式关闭数据库连接(避免 Windows 文件锁导致清理失败)
+  await cache.close()
+  
+  // 清理
+  await fs.rm(tmpDir, { recursive: true, force: true })
+})
+```
+
+### 7.3 测试覆盖率目标
+
+- **容错读写库**:每个 parser/serializer 覆盖正常路径 + 至少 2 个错误路径
+- **Storage Adapter**:每个端口主方法有正常用例 + 边界(不存在的 ID、空结果)
+- **缓存重建器**:删缓存重建、校验规则(履历章节不存在、别名冲突)
+- **41 接口**:P0 每个接口 ≥2 用例(正常+边界),P1/P2 每个接口 ≥1 用例
+
+## 8. 技术选型
+
+### 8.1 YAML 库
+
+**选项 A:js-yaml**
+- 优势:成熟、广泛使用、MIT、零传递依赖
+- 劣势:需引入一个运行时依赖(后端规范 §1.1 要求零依赖)
+
+**选项 B:手写轻量解析器**
+- 优势:零依赖
+- 劣势:开发成本高、边界情况多、YAML 规范复杂(1.2 版 100+ 页)
+
+**决策**:使用 `js-yaml` 解析 + 手写序列化,理由:
+1. YAML 规范复杂,手写解析器风险高(嵌套、转义、多行字符串、锚点等边界情况)
+2. `js-yaml` 本身零传递依赖(`npm ls js-yaml` 无子依赖)
+3. 防呆方言限制了输出格式复杂度(平铺、块列表),序列化可手写控制
+4. 后端规范 §1.1 的"零依赖"目标在于避免臃肿依赖树,单一成熟库(4.1.0, 19kB min+gzip)是可接受的折中
+
+**折中方案**:
+- 解析用 `js-yaml`(复杂、依赖成熟度)
+- 序列化手写(简单、可控、防呆方言强制执行)
+
+**风险缓解**:
+- `js-yaml` 是行业标准(每周 7000 万次下载、MIT、维护活跃)
+- prd Constraints C1 明确标注为"唯一例外"
+- 若未来需移除依赖,替换点单一(parsers/yaml-safe.js)
+
+### 8.2 文件扫描
+
+使用 Node 内置 `fs/promises` + `path`:
+- `fs.readdir(dir, { recursive: true, withFileTypes: true })`(Node ≥20)递归扫描
+- 按文件名前缀过滤(`0001-`、`伏笔-`)
+
+### 8.3 node:sqlite
+
+Node ≥ 22.13.0 内置(无需 `--experimental-sqlite` flag):
+```js
+import { DatabaseSync } from 'node:sqlite'
+
+const db = new DatabaseSync('path/to/db.db')
+db.exec('CREATE TABLE ...')
+const stmt = db.prepare('SELECT * FROM chapters WHERE chapter_num = ?')
+const rows = stmt.all(123)
+```
+
+**注意**:`node:sqlite` 是同步 API(`DatabaseSync`),但包在 async 函数里不影响上层异步调用。
+
+## 9. 风险与缓解
+
+### 9.1 风险:41 接口工作量大
+
+**缓解**:
+- 地基做扎实后,每个接口大多是"一次查询 + 格式化"的薄封装
+- 按 P0→P1→P2 分层,每层一 commit 检查点,可随时切分任务
+- P2 若时间紧,文体指纹相关接口(`report-style-drift`)可暂时返回"功能未实现"占位
+
+### 9.2 风险:YAML 解析边界情况多
+
+**缓解**:
+- 防呆方言限制了输出格式复杂度(平铺、块列表)
+- 容错读取只需"不崩溃 + 保留未知字段",不需 100% 正确解析
+- 依赖 `js-yaml` 的成熟度
+
+### 9.3 风险:Windows 中文路径测试假阴性
+
+**缓解**:
+- 所有 IO 显式 `encoding: 'utf8'`
+- CI Windows job 真实跑中文路径 fixture
+- 测试用 `os.tmpdir()` 动态构建,不依赖硬编码路径
+
+### 9.4 风险:重建器校验逻辑复杂
+
+**缓解**:
+- 履历章节验证只查文件存在性(`fs.access`),不做语义解析
+- 别名唯一性用 Map 去重,O(n) 复杂度
+- 两审负责语义校验(履历真假),重建器只做脚本能做的
+
+## 10. 边界与非目标
+
+**M1 做**:
+- ✅ 容错读取、防呆写出
+- ✅ Storage Adapter 小端口(Reader 全部、Writer 接口占位)
+- ✅ 五表 DDL + 全量重建器
+- ✅ 41 精准读取接口 CLI(P0/P1/P2 全部)
+- ✅ 删缓存重建 CI 测试
+
+**M1 不做**:
+- ❌ Writer 端口真实实现(M2 定稿流程调用)
+- ❌ 增量更新缓存(定稿时只写变化行)
+- ❌ 文体特征提取算法(`fingerprints` 表建好留空,M3+ 体检补)
+- ❌ CLI 命令注册机制优化(当前硬编码 switch,M3+ 重构)
+- ❌ 向量库、语义检索(7.x 可选插件)
+
+## 11. 实施检查清单
+
+阶段划分见 `implement.md`,此处列设计完备性检查:
+
+- [ ] 四层架构清晰(读写库 → 小端口 → 缓存 → CLI)
+- [ ] 每个端口接口签名明确(参数、返回值、错误处理)
+- [ ] 五表 DDL 可执行(语法正确、索引完整)
+- [ ] 重建器逻辑明确(扫描顺序、校验规则、错误处理)
+- [ ] 41 接口清单完整(对齐 O4 §2)
+- [ ] 测试策略覆盖 AC1-AC12(prd.md 验收标准)
+- [ ] 技术选型有理由(YAML 库、node:sqlite)
+- [ ] 风险有缓解措施
+- [ ] 边界清晰(Writer 占位、文体特征留空)
+
+---
+
+**设计完成**。下一步:`implement.md` 执行计划(四阶段 checklist + 验证命令 + 回滚点)。

+ 7 - 0
.trellis/tasks/06-27-m1-format-core/implement.jsonl

@@ -0,0 +1,7 @@
+{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "零依赖铁律、职责分界(脚本/AI)、精准读取要求"}
+{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "五表 DDL、重建器即格式参考实现、容错读取保留未知字段"}
+{"file": ".trellis/spec/backend/error-handling.md", "reason": "永不带栈崩溃、中文错误文案、可自愈优先"}
+{"file": ".trellis/spec/backend/directory-structure.md", "reason": "v7/ 包内布局(src/storage、src/cache)、测试镜像约定"}
+{"file": "docs/architecture/story-repo-spec-2026-06-10.md", "reason": "格式法律文本(§1 不变量、§2-6 文件格式、§11 缓存与精准读取)"}
+{"file": "docs/architecture/cache-design-2026-06-26.md", "reason": "五表 DDL 完整版、41 接口清单、派生值不物化策略、重建器职责"}
+{"file": "docs/architecture/v7-implementation-plan.md", "reason": "§1.5 架构原则(拆小端口、AI 不碰文件、状态机不判业务)"}

+ 355 - 0
.trellis/tasks/06-27-m1-format-core/implement.md

@@ -0,0 +1,355 @@
+# 执行计划:M1 格式层核心库 + 派生缓存
+
+> 前置:已读 prd.md、design.md,以及后端规范(质量/数据/错误/目录结构)+ spec 0.8 + O4 缓存设计。
+> 落点全部在 `v7/src/` 与 `v7/test/`;不碰 v6 与根遗产。
+> 本机命令:`cd v7 && node --test`(Node 24.15.0 可用);Python 脚本用 `PYTHONUTF8=1 python`。
+
+---
+
+## ⚠ 计划修正与重建(2026-06-27 review 后)
+
+**背景**:A–E 六个 commit 已落地,但 review 发现 D.2+D.3(`2bb34f6`)把 35 个 P1/P2 接口写成 `console.log('{}')` 空壳,且命令层零测试。根因是 design §6.2 旧契约自相矛盾(既 `return` 又 `console.log + process.exit`),命令无法单测。已 `git revert 2bb34f6` 撤回空壳。
+
+**修正后契约**(design §6.2 已改):命令导出 `run(args, options, ctx)` 只返回 `{ok, output?, error?}`,**不碰 process/console/cache 生命周期**;bin 唯一负责打印、退出码、`cache.ensureReady/close`。命令因此可单测,AC2 才成立。
+
+**重建顺序**(覆盖原 D.0–D.3,全部真实现 + 每接口 test/commands 测试):
+- [ ] R0 卫生:`v7/.gitignore` 忽略 `.cache/`;删冗余 `test/chinese-path.test.js`(保留 `test/integration/chinese-path.test.js`);rebuilder `scanCharacters` 改 upsert(修角色卡丢数据 bug)
+- [ ] R1 契约重构:bin 改 `run`+ctx 分发;6 个 P0 命令(read-chapter/read-thread/read-timeline/read-character/resolve-alias/report-overdue-threads)改 `run` 契约 + 补 `test/commands/*.test.js`
+- [ ] R2 fixture 扩充:补 `大纲/总纲.md`、`大纲/第01卷.md`、`定稿/设定/世界观.md`、`定稿/摘要/章摘要/0002.md`、悬念/感情线各一条目(design §7.1)
+- [ ] R3 P1 真实现 + 测试:list-chapters、list-secrets、grep-story(关键词)、report-book-stats、report-weak-hook-streak、read-chapter `--front-matter`(已在 P0 文件内)
+- [ ] R4 P2 真实现 + 测试:read-chapters(新建)、read-worldview(新建)、read-outline、list-volumes、list-threads、list-characters、read-secret、grep-story `--regex`、report-secret-accumulation、report-thread-activity、report-style-drift(边界占位:读指纹对比基线、不做特征提取)
+- [ ] R5 补齐 6 个缺失 adapter 测试 + 全量 AC1-AC12 复核
+
+**41 接口权威清单**见 prd.md AC2 表(21 命令文件承载)。下方原始 A–E checklist 保留作历史参考。
+
+---
+
+## 四阶段依赖链
+
+```
+阶段 A:容错读写库(parser/serializer)
+    ↓
+阶段 B:Storage Adapter 小端口(8+ Reader + Writer 占位)
+    ↓
+阶段 C:.cache/index.db 五表 + 重建器(node:sqlite)
+    ↓
+阶段 D:41 精准读取接口 CLI(分 P0/P1/P2 三层,每层 sub-commit)
+```
+
+每阶段独立验证 + 一次 commit 检查点(除阶段 D 分三 sub-commit)。
+
+---
+
+## 阶段 A:容错读写库
+
+### A1 YAML 依赖与基础工具
+
+- [ ] A1.1 安装 `js-yaml`:`cd v7 && npm install js-yaml`(MIT、零传递依赖)
+- [ ] A1.2 建 `v7/src/storage/parsers/` 与 `v7/src/storage/serializers/` 目录
+
+### A2 Front Matter 解析
+
+- [ ] A2.1 `v7/src/storage/parsers/front-matter.js`:
+  - `parseFrontMatter(content)` 函数(分离 `---` 包裹的 YAML 与 Markdown 正文)
+  - 返回 `{ok, data, body, error, rawYAML}`(rawYAML 用于保留未知字段)
+  - 容错:`---` 不存在/不配对 → ok=false;YAML 语法错误 → ok=false;不抛异常
+- [ ] A2.2 测试 `v7/test/storage/parsers/front-matter.test.js`:
+  - 正常路径:含 front matter 的章节文件
+  - 边界:无 front matter、单个 `---`、YAML 语法错误
+  - 断言:ok 值、error 中文、不崩溃
+
+### A3 YAML 安全解析与序列化
+
+- [ ] A3.1 `v7/src/storage/parsers/yaml-safe.js`:
+  - `parseYAML(yamlString, options)` 包装 `js-yaml.load`
+  - 捕获异常返回 `{ok: false, error}`
+  - 保留 rawYAML 字段(未来写回用)
+- [ ] A3.2 `v7/src/storage/serializers/yaml-dialect.js`:
+  - `serializeYAML(data)` 手写序列化(防呆方言)
+  - 规则(design §3.4):平铺检测、数组块格式、危险值引号、两空格缩进
+  - `needsQuoting(value)` 辅助函数
+- [ ] A3.3 测试 `v7/test/storage/parsers/yaml-safe.test.js`:
+  - 正常 YAML、语法错误、空字符串
+- [ ] A3.4 测试 `v7/test/storage/serializers/yaml-dialect.test.js`:
+  - 列表输出块格式(断言不含 `[a, b]`)
+  - 危险值加引号(`"123"`, `"true"`, `"A:B"`)
+  - 嵌套映射抛错
+
+### A4 容错读取保留未知字段
+
+- [ ] A4.1 `v7/src/storage/serializers/front-matter.js`:
+  - `serializeFrontMatter(data, body, originalYAML)` 组装 `---\nYAML\n---\n正文`
+  - 保留未知字段:从 originalYAML 提取非 data 中的字段,拼接到输出
+- [ ] A4.2 测试 `v7/test/storage/parsers/yaml-safe.test.js`(补充用例):
+  - 含 `自定义字段: 值` 的 YAML,解析 → 修改已知字段 → 序列化,断言自定义字段保留
+
+### A5 Markdown 表格与 book.yaml
+
+- [ ] A5.1 `v7/src/storage/parsers/markdown-table.js`:
+  - `parseMarkdownTable(content)` 提取表头与行(返回 `{ok, headers, rows, error}`)
+  - 容错:表头不对齐跳过、空行忽略、解析失败不崩溃
+- [ ] A5.2 `v7/src/storage/parsers/book-config.js`:
+  - `parseBookConfig(yamlString)` 读取平铺字段(spec §3)
+  - 返回 `{ok, data: {书名, 类型, 每章目标字数, ...}, error}`
+- [ ] A5.3 测试:
+  - `v7/test/storage/parsers/markdown-table.test.js`:时间线表、名册表
+  - `v7/test/storage/parsers/book-config.test.js`:正常 book.yaml、缺字段
+
+**验证 A**:`cd v7 && node --test test/storage/parsers/ test/storage/serializers/` 全绿
+
+**提交 A**:`feat(v7): M1 阶段 A——容错读写库(parser/serializer)`
+
+---
+
+## 阶段 B:Storage Adapter 小端口
+
+### B1 目录与基础
+
+- [ ] B1.1 建 `v7/src/storage/adapters/` 目录
+- [ ] B1.2 建 `v7/test/fixtures/sample-book/` 示例书仓库(design §7.1 完整示例):
+  - 至少 2 章:`定稿/正文/0001-开局.md`(含完整 front matter,见 design §7.1)、`0002-初遇.md`
+  - 1 角色卡:`定稿/设定/角色/林晚.md`(含 front matter + 设定/对话/关系段落)
+  - 1 伏笔:`大纲/伏笔/伏笔-001-神秘老者.md`(含履历格式示例)
+  - 1 信息差:`定稿/设定/信息差/信息差-001-灭门真凶.md`
+  - 1 时间线:`定稿/设定/时间线/第01卷.md`(Markdown 表格)
+  - 1 名册:`定稿/设定/名册.md`(Markdown 表格)
+  - 1 章摘要:`定稿/摘要/章摘要/0001.md`
+  - `book.yaml`(含基本配置,见 design §7.1)
+  - **照抄 design §7.1 完整示例**,不要自己编格式
+
+### B2 ChapterReader
+
+- [ ] B2.1 `v7/src/storage/adapters/ChapterReader.js`:
+  - 构造函数 `constructor(repoPath, cache)`(cache 可选)
+  - `readFrontMatter(chapterNum)`:优先查缓存 chapters 表,缺失时读文件
+  - `readBody(chapterNum)`、`readTail(chapterNum, wordCount)`、`readHead(...)`
+  - `readRange(start, end, fields)`
+- [ ] B2.2 测试 `v7/test/storage/adapters/ChapterReader.test.js`:
+  - 用 sample-book fixture
+  - 无缓存路径(直接读文件)
+  - 不存在章号返回 `{ok: false, error}`
+
+### B3 ThreadLedgerReader
+
+- [ ] B3.1 `v7/src/storage/adapters/ThreadLedgerReader.js`(design §4.3):
+  - `readBasicInfo(threadId)`、`readHistory(threadId)`、`readClosurePlan(threadId)`、`readDescription(threadId)`
+  - `listOverdue(bookConfig)`(查询时计算悬了太久章数)
+  - `listByType(type, status)`
+- [ ] B3.2 测试 `v7/test/storage/adapters/ThreadLedgerReader.test.js`:
+  - 读伏笔-001 基本信息、履历
+  - listOverdue 逻辑(mock 当前最大章号)
+
+### B4 EntityReader
+
+- [ ] B4.1 `v7/src/storage/adapters/EntityReader.js`(design §4.4):
+  - `readCharacterFrontMatter(name)`、`readCharacterFull(name)`
+  - `resolveAlias(alias)`(查 entity_aliases 表或解析名册)
+  - `listCharacters(filter)`
+- [ ] B4.2 测试:读林晚角色卡、解析别名
+
+### B5 其他 Reader 端口
+
+- [ ] B5.1 `TimelineReader.js`:`readCurrentVolume()`, `readVolumeRange(start, end)`, `readByParticipant(name)`
+- [ ] B5.2 `SecretReader.js`:`readBasicInfo(id)`, `readContent(id)`, `listUnrevealed()`
+- [ ] B5.3 `OutlineReader.js`:`readOutlineSection(type, volumeNum, sectionTitle)`, `listVolumes()`
+- [ ] B5.4 `BookConfigReader.js`:`read()` 返回 book.yaml 对象
+- [ ] B5.5 各自测试(至少正常路径)
+
+### B6 Writer 端口占位
+
+- [ ] B6.1 `ChapterWriter.js`、`ThreadLedgerWriter.js` 只定接口(抛 "M2 实现" 占位错误)
+- [ ] B6.2 `v7/src/storage/index.js` 统一导出所有端口
+
+**验证 B**:`cd v7 && node --test test/storage/adapters/` 全绿
+
+**提交 B**:`feat(v7): M1 阶段 B——Storage Adapter 小端口(8 Reader + Writer 占位)`
+
+---
+
+## 阶段 C:.cache/index.db 五表 + 重建器
+
+### C1 CacheManager 骨架
+
+- [ ] C1.1 `v7/src/cache/index.js`:
+  - `class CacheManager` 构造函数(dbPath)
+  - `ensureReady(repoPath)`:检查 db 存在性,不存在调用 `rebuildFromSource`
+  - `query(sql, params)`、`close()`
+  - 使用 `node:sqlite` 的 `DatabaseSync`
+- [ ] C1.2 `v7/src/cache/schema.js`:五表 DDL 字符串(design §5.1 完整 SQL)
+
+### C2 五表初始化
+
+- [ ] C2.1 `ensureReady` 内执行 schema.js 的 CREATE TABLE + CREATE INDEX
+- [ ] C2.2 测试 `v7/test/cache/CacheManager.test.js`:
+  - 创建临时 db,执行 DDL 不崩溃
+  - 查询空表返回 `[]`
+
+### C3 重建器核心逻辑
+
+- [ ] C3.1 `v7/src/cache/rebuilder.js`:`rebuildCache(repoPath, db)` 函数(design §5.3)
+  - 步骤 1-7:扫描源文件 → INSERT 各表
+  - 步骤 8:校验(履历章节存在性、别名唯一性)
+  - 返回 `{ok, warnings: [], errors: []}`
+- [ ] C3.2 扫描辅助函数:
+  - `scanChapters(repoPath)` 返回 Map<章号, 文件路径>(用于履历验证)
+  - `scanThreads(repoPath, type)` 扫描三类条目目录
+  - `scanEntities(repoPath)` 扫描角色卡 + 名册
+
+### C4 校验规则
+
+- [ ] C4.1 履历章节验证(spec 0.8 A3):
+  - `scanChapters` 建立章号 → 文件路径映射(Map)
+  - 解析履历行的"第N章",提取章号
+  - 查 Map,章号不存在 → 记 warning(不阻断重建)
+- [ ] C4.2 别名唯一性:
+  - 用 Map 收集 alias → entity_id 映射
+  - 发现冲突 → 记 error,`rebuildCache` 返回 `{ok: false, errors}`
+- [ ] C4.3 测试 `v7/test/cache/rebuilder.test.js`:
+  - 正常重建 sample-book
+  - 构造履历引用不存在章节(断言 warnings 非空)
+  - 构造别名冲突(断言 errors 非空、ok=false)
+
+### C5 集成测试:删缓存重建
+
+- [ ] C5.1 `v7/test/cache/rebuild-integration.test.js`:
+  - 用 sample-book,第一次重建,查询章数
+  - 删除 `.cache/index.db`
+  - 第二次重建,查询章数,断言相等(AC1)
+- [ ] C5.2 CI 验收项(prd AC1)在此测试覆盖
+
+**验证 C**:`cd v7 && node --test test/cache/` 全绿
+
+**提交 C**:`feat(v7): M1 阶段 C——.cache/index.db 五表 + 重建器(node:sqlite)`
+
+---
+
+## 阶段 D:41 精准读取接口 CLI(分三层)
+
+### D0 CLI 入口扩展
+
+- [ ] D0.1 `v7/bin/webnovel-writer.js` 增加子命令动态 import 分发(design §6.1):
+  - 读 `process.argv[2]` 为命令名
+  - `await import(\`../src/commands/\${命令}.js\`)`
+  - 模块不存在(ERR_MODULE_NOT_FOUND)→ 人话提示"未知命令"
+  - 调用 `commandModule.execute(args, options)`
+- [ ] D0.2 建 `v7/src/commands/` 目录
+
+### D1 P0 接口(8 个,写章流程依赖)
+
+- [ ] D1.1 `read-timeline.js`:`--current-and-prev` 选项(读当前卷+上一卷)
+- [ ] D1.2 `read-thread.js`:`--fields=基本信息` / `--履历`
+- [ ] D1.3 `read-chapter.js`:`--tail=N` / `--摘要`(从 `定稿/摘要/章摘要/NNNN.md` 读)
+- [ ] D1.4 `read-character.js`:`--front-matter`
+- [ ] D1.5 `resolve-alias.js`:输入别名,输出正名或"未找到"
+- [ ] D1.6 `report-overdue-threads.js`:按类型分组返回悬了太久清单(design §6.3 查询时计算)
+- [ ] D1.7 测试 `v7/test/commands/`:每个接口至少正常路径 + 边界(不存在 ID)
+- [ ] D1.8 bin 入口集成:`cd v7 && node bin/webnovel-writer.js read-chapter 1 --tail=100` 可执行
+
+**验证 D1**:P0 8 接口逐个手工冒烟 + `node --test test/commands/` P0 测试绿
+
+**提交 D1**:`feat(v7): M1 阶段 D.1——精准读取 P0 接口(写章流程依赖 8 个)`
+
+### D2 P1 接口(7 个,机检与全书近况)
+
+- [ ] D2.1 `read-chapter.js` 补充 `--front-matter` 选项
+- [ ] D2.2 `list-chapters.js`:`--章定位=推进` / `--卷=N` 筛选
+- [ ] D2.3 `list-secrets.js`:`--reader-knows=false`
+- [ ] D2.4 `grep-story.js`:`<关键词>` 全文检索(Grep 定稿/正文/*.md)
+- [ ] D2.5 `report-book-stats.js`:总章数/总字数/条目数/角色数
+- [ ] D2.6 `report-weak-hook-streak.js`:末尾连续弱钩章数(design §6.3,匹配"弱钩"或"-弱")
+- [ ] D2.7 测试 + 冒烟
+
+**验证 D2**:P1 7 接口冒烟 + 测试绿
+
+**提交 D2**:`feat(v7): M1 阶段 D.2——精准读取 P1 接口(机检与全书近况 7 个)`
+
+### D3 P2 接口(26 个,AI 角色与自动模式优化)
+
+- [ ] D3.1 条目读取(4 个):read-thread 补 `--收尾计划` / `--描述`,list-threads 补 `--type` / `--strength`
+- [ ] D3.2 大纲读取(5 个):read-outline 全套(总纲/卷纲/section/结局),list-volumes
+- [ ] D3.3 正文读取(4 个):read-chapter 补 `--head=N` / 正文全文,read-chapters `--range=start-end`
+- [ ] D3.4 时间线读取(3 个):read-timeline 补 `--current-volume` / `--卷=N` / `--在场=name`
+- [ ] D3.5 设定读取(4 个):read-character 补完整 / `--section`,list-characters,read-worldview `--section`
+- [ ] D3.6 信息差(1 个):read-secret `--内容`
+- [ ] D3.7 全文检索(1 个):grep-story `--regex=pattern`
+- [ ] D3.8 报表(4 个):
+  - report-secret-accumulation
+  - report-thread-activity
+  - report-style-drift(design §6.3:接口存在、能读已有指纹并对比基线,但不实现特征提取;表为空时返回友好错误"缺少指纹数据,请先运行体检")
+  - 测试策略:手工插入基线 + 最近数据到 fingerprints 表,测试对比逻辑
+- [ ] D3.9 测试 + 冒烟(至少正常路径)
+
+**验证 D3**:P2 26 接口批量冒烟(抽查 5 个详细测,其余正常路径测试绿)
+
+**提交 D3**:`feat(v7): M1 阶段 D.3——精准读取 P2 接口(AI 角色优化 26 个)`
+
+---
+
+## 阶段 E:收尾与文档
+
+### E1 Windows 中文路径 CI
+
+- [ ] E1.1 `v7/test/integration/chinese-path.test.js`(design §7.2 AC5):
+  - `os.tmpdir()` 下建 `测试书仓库/定稿/正文/0001-测试.md`
+  - 全链路(重建缓存 + 读取)
+  - **显式 `await cache.close()` 关闭数据库连接**(避免 Windows 文件锁导致清理失败)
+  - 清理临时目录
+- [ ] E1.2 `.github/workflows/v7-ci.yml` 已有 Windows job,此测试自动覆盖
+
+### E2 全量验证
+
+- [ ] E2.1 `cd v7 && node --test` 全绿(所有测试)
+- [ ] E2.2 过 prd.md AC1-AC12 验收清单(逐条检查对应测试存在)
+- [ ] E2.3 过后端规范质量评审清单(零依赖/文案中文/不带栈崩/职责分界)
+
+### E3 JSONL 清单更新
+
+- [ ] E3.1 `implement.jsonl` 填充真实条目(移除 `_example`):
+  - `{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "零依赖、职责分界、精准读取"}`
+  - `{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "五表 DDL、重建器、容错读取"}`
+  - `{"file": ".trellis/spec/backend/error-handling.md", "reason": "永不带栈崩、中文错误"}`
+  - `{"file": "docs/architecture/story-repo-spec-2026-06-10.md", "reason": "格式法律文本(§1-6、§11)"}`
+  - `{"file": "docs/architecture/cache-design-2026-06-26.md", "reason": "五表 DDL、41 接口清单、派生值策略"}`
+- [ ] E3.2 `check.jsonl` 填充:
+  - `{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "评审清单 §5(零依赖/术语表/错误处理/职责分界/文档先行)"}`
+  - `{"file": "docs/architecture/story-repo-spec-2026-06-10.md", "reason": "不变量 2(删缓存可重建)、不变量 9(保留未知字段)、§2.3 防呆方言"}`
+
+### E4 提交与归档
+
+- [ ] E4.1 `task.py current` 确认任务状态
+- [ ] E4.2 最终 commit(若有零散修复):`feat(v7): M1 收尾——CI 验收项 + JSONL 清单`
+- [ ] E4.3 推送 v7 分支,观察 CI 双平台(ubuntu + windows)全绿
+
+---
+
+## 回滚点
+
+- **阶段 A-D 各自独立**:未提交前 `git restore v7/` 对应子目录即可
+- **阶段 C 前可回退**:删除 `v7/src/cache/` 与 `v7/test/cache/`,不影响 A-B
+- **阶段 D 分三层 sub-commit**:任一层出问题回退到上一 commit
+
+## 提交计划(总 6 commits)
+
+1. `feat(v7): M1 阶段 A——容错读写库(parser/serializer)`
+2. `feat(v7): M1 阶段 B——Storage Adapter 小端口(8 Reader + Writer 占位)`
+3. `feat(v7): M1 阶段 C——.cache/index.db 五表 + 重建器(node:sqlite)`
+4. `feat(v7): M1 阶段 D.1——精准读取 P0 接口(写章流程依赖 8 个)`
+5. `feat(v7): M1 阶段 D.2——精准读取 P1 接口(机检与全书近况 7 个)`
+6. `feat(v7): M1 阶段 D.3——精准读取 P2 接口(AI 角色优化 26 个)`
+
+收尾工作并入 commit 6 或单独 commit 7(按实际情况)。
+
+---
+
+## 出口判据复核(对齐 prd Acceptance)
+
+- [ ] AC1:删 `.cache` 全量重建测试绿(test/cache/rebuild-integration.test.js)
+- [ ] AC2:41 接口逐条测试绿(test/commands/*.test.js)
+- [ ] AC3:容错读取保留未知字段测试绿(test/storage/parsers/yaml-safe.test.js)
+- [ ] AC4:防呆写出测试绿(test/storage/serializers/yaml-dialect.test.js)
+- [ ] AC5:Windows 中文路径测试绿(test/integration/chinese-path.test.js)
+- [ ] AC6-AC10:接口行为验收(各自测试覆盖)
+- [ ] AC11:小端口分离(storage/adapters/ 至少 8 个独立 Reader)
+- [ ] AC12:测试镜像 src(test/storage/、test/cache/、test/commands/ 与 src 对应)
+- [ ] CI 双平台绿(ubuntu + windows)
+- [ ] `v7/package.json` dependencies 仅 `js-yaml`(零传递依赖)

+ 283 - 0
.trellis/tasks/06-27-m1-format-core/prd.md

@@ -0,0 +1,283 @@
+# M1 格式层核心库 + 派生缓存
+
+## Goal
+
+实现 v7 的格式层基础设施,使系统能够可靠地读写 story repo 源文件,并通过缓存支撑精准读取。交付四层能力(依赖链):① 容错读写库 → ② Storage Adapter 小端口 → ③ `.cache/index.db` 五表+重建器 → ④ 41 精准读取接口 CLI。出口:删光 `.cache` 全量重建测试绿 + 精准读取 41 接口逐条测试绿。
+
+## Background
+
+**上游就绪**:
+- spec 0.8:格式法律文本(§1 不变量、§2-6 文件格式、§11 缓存)
+- O4 缓存设计(2026-06-26 定稿):五表 DDL、41 接口清单、优先级分层
+- 实施计划 §1.5:架构原则(拆小端口、AI 不碰文件、状态机不判业务)
+- 后端规范基线 1.1:零依赖、node:sqlite、容错读取、防呆方言、永不带栈崩
+- M0 骨架:`v7/src/storage/index.js`、`v7/src/cache/index.js` 占位已存在
+
+**范围决策**(2026-06-27):
+- 本任务一次交付全 41 个精准读取接口(分 P0/P1/P2 三层实现、每层一 commit 检查点)
+- 不拆父子任务(四块是依赖链不是独立交付物,拆了反而增加跨任务状态管理成本)
+
+## Requirements
+
+### R1 容错读写库(格式解析与序列化)
+
+**R1.1 Front matter 读写**
+- 解析章节文件的 YAML front matter(`---` 包裹),提取结构化字段
+- 解析三类条目文件(伏笔/悬念/感情线)的 front matter + Markdown 正文分区(描述/收尾计划/履历)
+- 解析角色卡、信息差、世界观、名册文件
+- **容错**:遇到未知字段必须保留原样(不变量 9);YAML 解析失败返回错误对象(不崩溃)
+
+**R1.2 平铺 YAML 与 book.yaml**
+- 读取 `book.yaml` 的平铺字段(spec §3)
+- 写出时强制防呆方言(spec §2.3、数据规范 §4):
+  - 一律平铺(禁止嵌套映射)
+  - 列表块格式(`- 项`,禁 `[a,b]`)
+  - 危险值加引号(数字串、含冒号/`true`/`null` 等易误判的字符串)
+  - 两空格缩进、UTF-8 无 BOM
+
+**R1.3 时间线 Markdown 表格**
+- 解析 `定稿/设定/时间线/第NN卷.md` 的 Markdown 表(`| 章 | 书内时间 | 一句话事件 | 在场 |`)
+- 提取行到结构化数组
+
+**R1.4 名册解析**
+- 解析 `定稿/设定/名册.md` 表(`| 正名 | 别名 | 类型 | 首现章 |`)
+- 建立别名→正名映射
+
+### R2 Storage Adapter 小端口(架构原则 §1.5)
+
+**禁止上帝对象**:不做 20 方法的 `StoryRepo` 大类,拆成职责最小的独立端口:
+
+- `ChapterReader`:读章节 front matter、正文、指定范围
+- `ChapterWriter`:写新章到定稿(M2 调用,本任务只定接口)
+- `ThreadLedgerReader`:读三类条目(伏笔/悬念/感情线)基本信息、履历、收尾计划
+- `ThreadLedgerWriter`:更新条目状态、追加履历(M2 调用,本任务只定接口)
+- `EntityReader`:读角色卡、解析别名、列出实体
+- `TimelineReader`:读指定卷时间线、按在场过滤
+- `SecretReader`:读信息差基本信息、内容
+- `OutlineReader`:读总纲/卷纲指定小节
+- `BookConfigReader`:读 `book.yaml`
+
+每个端口只暴露 2-5 个方法,调用方按需依赖(权限最小化)。
+
+### R3 `.cache/index.db` 五表与重建器
+
+**R3.1 五表 DDL**(O4 §1,表名英文、内容中文)
+- `chapters`:章 front matter 展开(章号/标题/卷/视角/章定位/钩子/情绪定位/字数/file_path)
+- `threads`:三类条目统一存放(id/type/short_title/strength/status/opened_chapter/planned_end/last_advanced_chapter/file_path)
+- `secrets`:信息差(id/short_title/known_to JSON/reader_knows/registered_chapter/keywords JSON/file_path)
+- `entities`:名册(id 即正名/type/status/location/realm/possessions JSON/last_changed_chapter/file_path)
+- `entity_aliases`:别名表(alias PK/entity_id FK)
+- `fingerprints`:文体指纹(chapter_range_start+end 复合 PK/is_baseline/常用特征列/fingerprint_data JSON)
+
+索引:按 O4 §1 各表建必要索引(volume_num、type、status、reader_knows、is_baseline 等)
+
+**R3.2 重建器**(node:sqlite,O4 §4)
+- 输入:只读 `定稿/`、`大纲/`、`文风/`(不读工作区)、`book.yaml`
+- 输出:全量重建五表(DELETE 旧数据 → 扫描源文件 → INSERT)
+- 校验:
+  - 履历证据章节文件存在性(spec 0.8 A3 决策)
+  - 别名唯一性(同一别名不能指向多个实体)
+- 触发:`.cache/index.db` 不存在或损坏时
+- **不做**:语义验证(履历真假、泄密真假)→ 两审负责
+
+**R3.3 派生值策略**(O4 §0 原则 5)
+- **不物化**:悬了太久章数(`当前最大章号 − last_advanced_chapter`)、信息差蓄积章数、是否超期 → 查询时算
+- **不存**:随当前章号漂移的任何值
+- 理由:避免每章定稿刷新全表,避免失同步
+
+### R4 精准读取接口 CLI(41 个,O4 §2)
+
+**命令式 API**:每个接口是一个明确的脚本命令,可在 CLI 调用、AI 友好、默认精准、统一 JSON/文本输出。
+
+**P0(8 个,写章流程依赖)**:
+- 时间线:读当前卷+上一卷
+- 条目:读条目基本信息、读条目履历
+- 正文:读近 N 章结尾、读章摘要
+- 设定:读角色卡 front matter、解析别名
+- 报表:悬了太久清单
+
+**P1(7 个,机检与全书近况)**:
+- 正文:读章节 front matter、按章定位筛选
+- 信息差:列出未揭晓信息差
+- 全文检索:关键词全文检索
+- 报表:全书统计摘要、连续弱钩计数
+
+**P2(26 个,AI 角色与自动模式优化)**:
+- 条目:读条目收尾计划、读条目描述、列出某类型条目、按强度筛选
+- 大纲:读总纲指定小节、读总纲结局段、读卷纲全文、读卷纲指定小节、列出所有卷纲
+- 正文:读章节正文、读章节结尾 N 字、读章节开头 N 字、读章节范围摘要
+- 时间线:读当前卷、读指定卷、按在场角色筛选
+- 设定:读角色卡完整、读角色卡指定小节、列出所有角色、读世界观指定小节
+- 信息差:读信息差内容
+- 全文检索:正则表达式检索
+- 报表:信息差蓄积报表、文体漂移报告、条目活跃率报表
+
+完整清单见 O4 §2.2-2.9 各表(本 PRD 不重复列全部参数)。
+
+## Constraints
+
+### 技术约束(后端规范 + spec 不变量)
+
+**C1 零第三方依赖**(质量规范 §1.1)
+- 缓存用 `node:sqlite`(内置)
+- 测试用 `node:test` + `node:assert`(内置)
+- **唯一例外**:YAML 解析用 `js-yaml`(MIT、零传递依赖、YAML 规范复杂不适合手写);序列化手写(简单、可控)
+
+**C2 容错与防呆**(不变量 1/9,数据规范 §4)
+- 任何源文件解析失败不得崩溃,返回错误对象
+- 未知字段保留原样写回
+- 系统写出的 YAML 强制防呆方言(平铺/块列表/危险值引号)
+
+**C3 编码与平台**(质量规范 §3)
+- 所有文件 IO 显式 UTF-8 无 BOM
+- Windows 中文路径必须正确处理(CI 验收项)
+- 文件排序靠零填充数字前缀(`0152-`、`伏笔-031`),不依赖中文字典序
+
+**C4 职责分界**(质量规范 §2.1)
+- 本任务只做脚本能做的:文件 IO、格式解析、SQL 查询、字符串拼接
+- 不做语义判断(履历真假、泄密真假)→ 两审负责
+- 禁止用正则凑语义判断
+
+**C5 错误处理**(错误规范 §1)
+- 永不带堆栈崩溃
+- 面向作者的错误全中文、说清发生了什么/影响/该怎么办
+
+## Acceptance Criteria
+
+### 核心验收(CI 强制)
+
+**AC1 删光 `.cache` 全量重建**(不变量 2)
+- [ ] `.cache/index.db` 不存在时,首次查询触发全量重建
+- [ ] 重建后五表填充完整,所有查询正确返回
+- [ ] 删除 `.cache/` 后再次查询,行为不变(可重建)
+- [ ] CI 测试:删缓存→重建→验证查询结果与预期一致
+
+**AC2 41 精准读取接口逐条测试**
+- [ ] 每个接口有至少一个测试用例(喂真实 fixture 或 mock 数据,断言输出格式与内容)
+- [ ] P0 接口测试覆盖典型输入与边界(空结果、不存在的 ID、范围越界)
+- [ ] P1/P2 接口至少有正常路径测试
+
+**AC2 权威 41 接口清单**(对齐 O4 §2.2-2.9,命令层测试逐条核对此表):
+
+| # | 分类 | 接口 | 命令 |
+|---|------|------|------|
+| 1 | 条目 | 读条目基本信息 | `read-thread <id> --fields=基本信息` |
+| 2 | 条目 | 读条目履历 | `read-thread <id> --履历` |
+| 3 | 条目 | 读条目收尾计划 | `read-thread <id> --收尾计划` |
+| 4 | 条目 | 读条目描述 | `read-thread <id> --描述` |
+| 5 | 条目 | 列出悬了太久 | `list-threads --悬了太久` |
+| 6 | 条目 | 列出某类型 | `list-threads --type=<t> [--status=<s>]` |
+| 7 | 条目 | 按强度筛选 | `list-threads --strength=高` |
+| 8 | 大纲 | 读总纲指定小节 | `read-outline --总纲 --section=<标题>` |
+| 9 | 大纲 | 读总纲结局段 | `read-outline --总纲 --结局` |
+| 10 | 大纲 | 读卷纲全文 | `read-outline --卷=<N>` |
+| 11 | 大纲 | 读卷纲指定小节 | `read-outline --卷=<N> --section=<标题>` |
+| 12 | 大纲 | 列出所有卷纲 | `list-volumes` |
+| 13 | 正文 | 读章节 front matter | `read-chapter <num> --front-matter` |
+| 14 | 正文 | 读章节正文 | `read-chapter <num>` |
+| 15 | 正文 | 读章节结尾 N 字 | `read-chapter <num> --tail=<N>` |
+| 16 | 正文 | 读章节开头 N 字 | `read-chapter <num> --head=<N>` |
+| 17 | 正文 | 读章摘要 | `read-chapter <num> --摘要` |
+| 18 | 正文 | 读章节范围摘要 | `read-chapters --range=<a>-<b> --摘要` |
+| 19 | 正文 | 读近 N 章结尾 | `read-chapters --recent=<N> --tail=<M>` |
+| 20 | 正文 | 按章定位筛选 | `list-chapters --章定位=推进 [--卷=<N>]` |
+| 21 | 时间线 | 读当前卷 | `read-timeline --current-volume` |
+| 22 | 时间线 | 读当前卷+上一卷 | `read-timeline --current-and-prev` |
+| 23 | 时间线 | 读指定卷 | `read-timeline --卷=<N>` |
+| 24 | 时间线 | 按在场筛选 | `read-timeline --在场=<角色名>` |
+| 25 | 设定 | 读角色卡 front matter | `read-character <name> --front-matter` |
+| 26 | 设定 | 读角色卡完整 | `read-character <name>` |
+| 27 | 设定 | 读角色卡指定小节 | `read-character <name> --section=<标题>` |
+| 28 | 设定 | 解析别名 | `resolve-alias <别名>` |
+| 29 | 设定 | 列出所有角色 | `list-characters [--status=<s>]` |
+| 30 | 设定 | 读世界观指定小节 | `read-worldview --section=<标题>` |
+| 31 | 信息差 | 读信息差基本信息 | `read-secret <id> --基本信息` |
+| 32 | 信息差 | 读信息差内容 | `read-secret <id> --内容` |
+| 33 | 信息差 | 列出未揭晓 | `list-secrets --reader-knows=false` |
+| 34 | 检索 | 关键词全文检索 | `grep-story <关键词>` |
+| 35 | 检索 | 正则检索 | `grep-story --regex=<pattern>` |
+| 36 | 报表 | 悬了太久清单 | `report-overdue-threads` |
+| 37 | 报表 | 信息差蓄积 | `report-secret-accumulation` |
+| 38 | 报表 | 文体漂移(M1 边界:读表对比基线,不做特征提取) | `report-style-drift` |
+| 39 | 报表 | 条目活跃率 | `report-thread-activity --卷=<N>` |
+| 40 | 报表 | 连续弱钩计数 | `report-weak-hook-streak` |
+| 41 | 报表 | 全书统计摘要 | `report-book-stats` |
+
+合计 7+5+8+4+6+3+2+6 = **41**,分布于 21 个命令文件(多接口共享文件,靠 `--选项` 分流)。
+
+**AC3 容错读取**(不变量 9)
+- [ ] front matter 含未知字段时,解析不崩溃,字段保留
+- [ ] 写回时未知字段原样出现在输出 YAML 中
+- [ ] 测试:构造含 `自定义字段: 值` 的章节文件,读→改标题→写,自定义字段不丢失
+
+**AC4 防呆写出**(数据规范 §4)
+- [ ] 写出的 front matter 一律平铺(无嵌套映射)
+- [ ] 列表一律块格式(每行 `- 项`)
+- [ ] 危险值加引号(`"123"` 不误判数字、`"true"` 不误判布尔)
+- [ ] 测试:写含列表/数字串/含冒号字符串的 YAML,手工验证输出格式
+
+**AC5 Windows 中文路径全链路**(质量规范 §3.2)
+- [ ] CI Windows job 包含中文目录名与文件名的 fixture
+- [ ] 读写、重建、查询全流程在中文路径下通过
+
+### 接口行为验收(抽查典型)
+
+**AC6 章节读取**
+- [ ] `read-chapter <num> --front-matter`:返回 JSON 含章号/标题/卷/视角等字段
+- [ ] `read-chapter <num> --tail=500`:返回正文末尾 500 字(不含 front matter)
+- [ ] 不存在的章号返回明确错误(不崩溃)
+
+**AC7 条目读取**
+- [ ] `read-thread <id> --fields=基本信息`:返回强度/状态/开启章/预计收尾
+- [ ] `read-thread <id> --履历`:返回履历列表(按章号排序)
+- [ ] `list-threads --悬了太久`:计算 `当前最大章号 − last_advanced_chapter`,返回超过阈值的条目(阈值从 `book.yaml` 读取)
+
+**AC8 别名解析**
+- [ ] `resolve-alias <别名>`:返回正名
+- [ ] 未登记别名返回"未找到"(不崩溃)
+- [ ] 同一别名指向多个正名时,重建器报错(别名唯一性校验)
+
+**AC9 报表生成**
+- [ ] `report-overdue-threads`:按类型分组返回悬了太久的条目清单
+- [ ] `report-book-stats`:返回总章数/总字数/条目数/角色数
+- [ ] `report-weak-hook-streak`:返回末尾连续弱钩章数
+
+**AC10 重建器校验**
+- [ ] 履历引用不存在的章节文件时,重建器记录警告(不阻断重建,但输出可见)
+- [ ] 别名冲突时,重建器报错并拒绝重建
+
+### 架构验收
+
+**AC11 小端口分离**(架构原则 §1.5)
+- [ ] `storage/` 导出至少 8 个独立 reader 端口(ChapterReader / ThreadLedgerReader / EntityReader / TimelineReader / SecretReader / OutlineReader / BookConfigReader + 未来 Writer 占位)
+- [ ] 每个端口方法数 ≤ 5
+- [ ] 测试中可单独 import 某一端口,不依赖其他端口实例化
+
+**AC12 测试镜像 src**
+- [ ] `v7/test/storage/` 与 `v7/src/storage/` 镜像
+- [ ] `v7/test/cache/` 与 `v7/src/cache/` 镜像
+- [ ] 测试文件命名 `*.test.js`
+
+## Out of Scope
+
+- ❌ Writer 端口的真实实现(M2 定稿流程调用,本任务只定接口占位)
+- ❌ 增量更新缓存(定稿时只写变化行)→ M2 优化
+- ❌ 文体指纹特征提取算法(`fingerprints` 表建好但留空,特征提取随 M3+ 体检/卷复盘补);`report-style-drift` 接口存在、能读表并对比基线,但 M1 不实现特征提取函数
+- ❌ 向量库与语义检索(可选插件,7.x)
+- ❌ CLI 子命令注册机制优化(当前动态 import 按命令名加载,M3+ 可做统一注册表)
+
+## Open Questions
+
+无阻断问题。以下为实施细节,进入 design 阶段决策:
+
+- YAML 库选型:`js-yaml`(零依赖、MIT)vs 手写轻量解析器?
+- 测试 fixture 布局:`v7/test/fixtures/` 放示例书仓库?
+- CLI 命令 41 个如何组织:全扁平 `bin/commands/*.js` 还是按类型分组子目录?
+- 文体指纹 `fingerprints` 表在 M1 只建表结构 + 占位查询接口,特征提取函数留桩——这个边界在 design 明确
+
+## Notes
+
+- 本任务是 M2-M6 的地基:M2 写章流程脚本依赖 P0 接口、M3 状态机依赖全书近况、M4 AI 角色依赖完整读取面
+- 四块交付物是依赖链(容错读写库 → 小端口 → 五表+重建 → 41 接口),不拆父子任务
+- 实施分四阶段(每阶段一 commit):① 容错读写库 → ② Storage Adapter 端口 → ③ 五表+重建器 → ④ 41 接口分层(P0→P1→P2,每层 sub-commit)
+- 后端规范 + spec 0.8 + O4 是法律文本,本 PRD 与之冲突时以它们为准

+ 26 - 0
.trellis/tasks/06-27-m1-format-core/task.json

@@ -0,0 +1,26 @@
+{
+  "id": "m1-format-core",
+  "name": "m1-format-core",
+  "title": "M1 格式层核心库 + 派生缓存",
+  "description": "容错读写库(front matter/平铺YAML/条目文件/book.yaml,保留未知字段+写出防呆)+ Storage Adapter 小端口 + index.db 缓存与重建器 + 41 精准读取接口 CLI。出口:删光 .cache 全量重建测试绿 + 精准读取逐条测试",
+  "status": "in_progress",
+  "dev_type": null,
+  "scope": null,
+  "package": null,
+  "priority": "P1",
+  "creator": "codex",
+  "assignee": "claude",
+  "createdAt": "2026-06-27",
+  "completedAt": null,
+  "branch": null,
+  "base_branch": "v7",
+  "worktree_path": null,
+  "commit": null,
+  "pr_url": null,
+  "subtasks": [],
+  "children": [],
+  "parent": null,
+  "relatedFiles": [],
+  "notes": "",
+  "meta": {}
+}