Explorar el Código

feat(v7): M5 基座——工作目录定位三分支 + books.jsonl 写侧 + next --json

- bin 命令分级(scope)与定位(src/runtime/locate.js):书仓库直启/工作目录当前书/人话提示
- session 写侧 registerBook/setCurrentBook/touchLastOpened,自愈抽 loadBooks 单源
- 新命令 list-books/switch-book/session-context(两宿主注入同源)
- 空工作目录 next → 状态机序1 建书引导(spec §10 序1)
- M5 规划三件套入库;314 测试绿
lingfengQAQ hace 18 horas
padre
commit
937ea487ba

+ 1 - 0
.trellis/tasks/07-03-m5-installer/check.jsonl

@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

+ 123 - 0
.trellis/tasks/07-03-m5-installer/design.md

@@ -0,0 +1,123 @@
+# M5 设计:安装器与多本书 + F1 宿主 CLI 缝
+
+> 上游:prd.md(同目录)。行为依据 multi-agent spec v3.5 §5.5/§5.8/§5.9/§8/§9、story-repo spec 0.9 §2.0/§2.1、实施计划 0.3 M5。
+
+## 1. 架构与边界
+
+新增/改动集中在五处,互不越界:
+
+| 模块 | 职责 | 不做 |
+|---|---|---|
+| `bin/webnovel-writer.js` | 命令分级(工作目录级 / 书级)+ 工作目录定位 | 业务逻辑 |
+| `src/installer/` | init/update 编排:环境检测、vendor 复制、模板哈希清单、壳落位、AGENTS.md 标记块 | 不联网、不改全局配置、不建 git 仓库 |
+| `src/session/`(扩展) | books.jsonl 写侧:登记 / 换书 / 最后打开;与 M4 读侧同模块,格式单源 | 不碰书仓库内容 |
+| `src/commands/`(新增 12 个) | F1 缝 + 多本书 + 安装器的 CLI 薄壳,全部委托用例层 | 不自带业务判断 |
+| `skills/` + `src/host-shells/` + `adapters/` | 命令引用语法模板变量、SKILL.md 写章流程接 F1 通道、registry 扩字段、hook 接线 | 生成器仍不联网、确定性不变 |
+
+### 1.1 命令分级与工作目录定位(B2)
+
+命令模块新增可选导出 `export const scope = 'workdir'`(缺省 `'book'`)。bin 启动流程:
+
+1. `--version` / `--help` / 无命令:现状不变。
+2. `scope === 'workdir'` 的命令(init / update / list-books / switch-book / session-context):ctx 给 `{ workdir: process.cwd(), packageRoot }`,**不建 CacheManager**(init 时书都不存在)。
+3. `scope === 'book'` 的命令,按序判定 repoPath:
+   - cwd 含 `book.yaml` → 书仓库直启,`repoPath = cwd`(兼容现有全部测试与开发场景);
+   - cwd 含 `.webnovel/` → 读 `books.jsonl`(复用 M4 `readBooksRegistry` + 自愈),取「当前」书 → `repoPath = cwd/<目录>`,并更新该书「最后打开」;无当前书 → 人话指引(「说“建书”开始第一本 / 用 switch-book 选一本」)退出码 1;
+   - 两者皆无 → 人话提示「请从工作目录启动(目录下应有 .webnovel/),或在书仓库根目录运行」。
+4. 例外:`persist-book` 声明 `scope = 'workdir-or-book'`——建书时书目录还不存在(见 §3.4)。
+
+### 1.2 `.webnovel/` 内容(vendoring,spec §2.0 既定)
+
+```text
+.webnovel/
+├── bin/webnovel-writer.js      # 与包同源复制
+├── src/**                      # 运行时全量
+├── roles/*.md                  # 角色任务书单源(三级宿主兜底可读,spec §2.0「角色定义」)
+├── node_modules/js-yaml/**     # 唯一运行时依赖,从安装器自身的解析位置复制
+├── package.json                # 供 --version
+├── manifest.json               # 模板哈希清单(§3.2),installer 写
+└── books.jsonl                 # 用户数据,installer 只在缺失时建空文件,永不覆盖
+```
+
+- 复制源 = 安装器自身包根(`import.meta.url` 上溯),js-yaml 用 `createRequire(import.meta.url).resolve('js-yaml/package.json')` 定位后整目录复制——npx / npm pack / repo 内直跑三种形态同一逻辑。
+- `skills/ adapters/ templates/` 不进 `.webnovel/`:它们只在 init/update 时由**当前运行的包**消费。vendored 副本里跑 `update` 时检测到自己位于 `.webnovel/` 下,提示改用 `npx webnovel-writer update`(自己更新自己是空转)。
+
+### 1.3 平台壳落位与 hook 接线(A1/B4)
+
+- registry.json 每宿主扩两个字段:`detect_bin`(PATH 探测的可执行名:claude / codex / gemini / cursor-agent)、`install_dir`(`.claude` / `.codex` / `.gemini` / `.cursor`)。validator 同步校验一二级宿主必有这两字段。
+- 落位 = 生成器输出的相对布局平移进 `install_dir`:`skills/webnovel-writer/SKILL.md`、`agents/<角色>.md|.toml`。
+- claude-code(`hasHooks: true`)额外接线 SessionStart:`.claude/settings.json` 的 `hooks.SessionStart` 加一条 `node .webnovel/bin/webnovel-writer.js session-context`。settings.json 已存在时做保留式合并(只增本项,按 command 字符串幂等判重);不存在时新建最小结构。settings.json **不进哈希清单**(用户主权文件,update 只做同样的幂等合并)。
+- 检测:扫 `PATH` 各目录找 `detect_bin`(win32 追加 PATHEXT 扩展名);纯函数 + 注入 env,可测。`--hosts=a,b` 显式覆盖探测;一个都没检测到 → 只装公约数层(AGENTS.md + `.webnovel/`),报告里指引用 `--hosts` 补装。
+
+### 1.4 命令引用语法模板变量(A4)
+
+- 生成器上下文加 `cmd: 'node .webnovel/bin/webnovel-writer.js'`;SKILL.md 里所有 `webnovel-writer <子命令>` 改写 `{{cmd}} <子命令>`。各宿主当前同值,仍走变量(spec §5.9)。
+- SKILL.md 写章流程同步接 F1 通道:备料/机检不变,第 3 步接 `review-input` → 两审(subagent 或顺序自审)→ `save-review`,第 4 步接 `finalize`;「继续」接 `next --json`(AI 吃 DTO)。drift check 逻辑不变(仍是确定性对比)。
+
+## 2. F1 CLI 缝契约(C)
+
+通则:JSON 输入一律 `--file=<路径>`(UTF-8 文件,杜绝 stdin 编码雷区);stdout 输出人话或 JSON(`--json` 时);失败人话报错 + 退出码 1,永不带栈。
+
+| 命令 | 入 | 出 | 委托 |
+|---|---|---|---|
+| `next [--json]` | — | `--json`:完整 `{ok, gitHealth, 序, state, needsAI, message, dto}`;缺省人读不变 | `determineNextState` |
+| `review-input <章号> [--draft=路径]` | draft 缺省 `工作区/草稿-A.md`(与 mechanical-check 一致) | 写 `工作区/审稿输入.json`,stdout 报路径(大 JSON 走文件,宿主用读文件工具吃) | `assembleReviewInput` |
+| `save-review <章号> --file=<两审json>` | `{factCheck, editorial, mode?, 待确认新专名?, 章摘要?}` | schema 校验→合并→落 `工作区/审稿.md` + `评审报告/`,stdout 报阻断数与路径 | 从 `runReviews` 抽出 `saveReviews`(校验+合并+落盘),两处共用不双写 |
+| `persist-outline --file=` | `{细纲}` | 落 `工作区/细纲.md` | `persistDraftOutline` |
+| `persist-book --file= [--dir=目录名]` | `{book, 总纲, 卷纲}` | 建书目录(workdir 模式下 `--dir` 或书名)+ 落盘 + git init + **指路 AGENTS.md + books.jsonl 登记并置当前** | `persistCreateBook`(扩展)+ `registerBook` |
+| `persist-volume-review --file=` | `{卷号, 卷摘要, 下卷卷纲?, 伏笔条目?}` | 落卷摘要/下卷卷纲/伏笔条目 | `persistVolumeReview` |
+| `persist-repair --file=` | `{repairs:[{file,content}]}` | 安全网校验后写回(allowedFiles = 当前检测失败清单,命令内现算) | `persistRepair` + `detectParseFailures` |
+| `finalize <章号> --payload=<json路径>` | 定稿包(`finalizeChapter` payload 全字段) | 原子 commit + 缓存刷新,stdout 报 commit 与下一步 | `finalizeChapter` |
+
+多本书命令:`list-books`(书单+当前标记)、`switch-book <书名>`(改「当前」+ 最后打开;模糊匹配失败列候选)、`session-context`(输出 `assembleSessionContext().text`,hook 与无 hook 宿主同源,注入逐字一致)。
+
+## 3. 数据契约
+
+### 3.1 books.jsonl 行
+
+`{"书名": "...", "目录": "...", "当前": true|false, "最后打开": "2026-07-03"}`——M4 读侧已容忍未知字段;写侧保证单一「当前」。扫描重建行缺「最后打开」可(重建即丢,属可再生信息)。
+
+### 3.2 manifest.json(模板哈希清单)
+
+```json
+{ "version": "7.0.0-alpha", "files": { "<工作目录相对路径>": "sha256-hex" } }
+```
+
+- 覆盖 installer 写出的所有文件;例外:`books.jsonl`(用户数据)、`.claude/settings.json`(合并式,见 §1.3)。
+- `AGENTS.md` 记**标记块内容**的哈希(update 只动块内,块外用户区不参与判改)。
+- update 三态:磁盘哈希 == 清单哈希 → 覆写新版并更新清单;!= → 用户改过,跳过并列出(`--force` 覆盖);文件缺失 → 重建。
+- 重复 `init` 检测到 manifest.json 存在 → 直接走 update 语义并在报告言明。
+
+### 3.3 init 报告(stdout,人话)
+
+装到哪、Node 版本判定、检测到/未检测到的宿主与支持等级(tier + support.md 口径)、降级说明(无 subagent → 兼容模式声明)、下一步(「打开 <宿主> 对它说:开始写书」)。
+
+### 3.4 persist-book 的目录判定
+
+- 书仓库直启(cwd 有 book.yaml):落 cwd,不登记(无工作目录层,兼容测试)。
+- 工作目录模式:目录名 = `--dir` 或 payload 书名 → `workdir/<目录>/`;已存在同名目录且含 book.yaml → 人话报错防覆盖。落盘成功后 `registerBook` 置当前。
+
+## 4. 兼容与迁移
+
+- 现有测试全部不动仍绿:书仓库直启分支保证 `repoPath = cwd` 行为不变;`next` 缺省输出不变;`runReviews` 对外签名不变(内部抽 `saveReviews`)。
+- dist/ 布局、drift check、validator 兼容:registry 新字段过 validator 需同步 validator 规则(一二级宿主必备 detect_bin/install_dir)。
+- `package.json` 加 `files` 白名单(bin/src/roles/skills/adapters/templates),npm pack 产物即安装源——CI 用 pack 产物验收(PRD Q1 决策:真发 npm 推迟 beta)。
+- `persistCreateBook` 新增指路 AGENTS.md 落盘:main-loop 测试建书断言不受影响(新增文件,不改旧文件)。
+
+## 5. 关键取舍
+
+| 决策 | 取 | 舍与理由 |
+|---|---|---|
+| 运行形态 | vendoring 进 `.webnovel/`(spec 既定) | npx 每次解析:离线不可用、版本漂移、可诊断性差(v6 「装哪了/读的哪份」教训) |
+| JSON 输出 | `next --json` 走 stdout;`review-input` 落文件 | ReviewInput 含草稿全文(大),落文件让宿主用读文件工具分段吃;DTO 小,stdout 管道友好 |
+| CLI 探测 | PATH 纯函数探测 + `--hosts` 覆盖,非交互 | 交互问答会卡死「AI 替作者跑 init」的主场景 |
+| 未检测到宿主 | 只装公约数层 + 指引 | 无条件全装偏离 spec「按检测生成」,且掩盖探测失败 |
+| settings.json | 幂等合并,不进哈希清单 | 全量覆盖会毁用户自己的 hooks/权限配置 |
+| update 冲突 | 默认跳过列清单 + `--force` | 交互确认在非交互环境挂死;静默覆盖违反 spec §8.2 |
+
+## 6. 运维与回滚
+
+- init/update 幂等可重跑;中途失败重跑即修复(哈希清单最后写,半程失败下次按缺失/未变重建)。
+- 不产生 git 操作(工作目录非 git 仓库;书仓库 git 仍只由建书/定稿路径管)。
+- 回滚 = 删工作目录重装;书仓库是独立 git 仓库,安装层操作永不触碰书内容(books.jsonl 登记除外,且可扫描重建)。
+- 发布判据联动:AC1 的 CI 用例即 beta 判据「Windows 中文路径全链路」的 M5 补全,M7 前持续绿。

+ 1 - 0
.trellis/tasks/07-03-m5-installer/implement.jsonl

@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

+ 70 - 0
.trellis/tasks/07-03-m5-installer/implement.md

@@ -0,0 +1,70 @@
+# M5 执行计划
+
+> 依赖序推进,每步含测试(node:test,零第三方)。验证基线命令见 §V。
+
+## 0. 前置
+
+- [ ] `task.py start`(用户确认后);分支即 v7(沿 M1-M4 惯例,不另开)
+
+## 1. 基座:工作目录定位 + books.jsonl 写侧(PRD B1/B2)
+
+- [ ] 1.1 `src/session/index.js` 扩写侧:`registerBook(workdir, {书名,目录})`(置当前、去重、补最后打开)、`setCurrentBook(workdir, 书名)`、`touchLastOpened(workdir, 目录)`;行格式测试与 M4 读侧同文件
+- [ ] 1.2 `bin/webnovel-writer.js` 命令分级:命令模块可选 `export const scope`;workdir 级不建 CacheManager;书级三分支定位(book.yaml 直启 / .webnovel+当前书 / 人话提示)——design §1.1
+- [ ] 1.3 新命令 `list-books` / `switch-book <书名>` / `session-context`(scope=workdir)
+- [ ] 1.4 测试:定位三分支、换书后书级命令作用于新书、session-context 与 M4 assembleSessionContext 输出逐字一致、损坏 books.jsonl 自愈路径经 CLI 仍成立
+
+## 2. F1 CLI 缝(PRD C,不依赖安装器)
+
+- [ ] 2.1 `next --json`:完整 DTO 上 stdout;缺省人读不变(改 `src/commands/next.js`)
+- [ ] 2.2 `persist-outline` / `persist-volume-review` / `persist-repair`:`--file=` 读 JSON → 委托既有用例;persist-repair 内算 allowedFiles(detectParseFailures)
+- [ ] 2.3 `persist-book`:scope=workdir-or-book;目录判定(design §3.4);`persistCreateBook` 扩指路 AGENTS.md(`templates/` 加书仓库模板);工作目录模式落盘后 registerBook 置当前
+- [ ] 2.4 `src/review/index.js` 抽 `saveReviews`(校验+合并+落盘),`runReviews` 改调它;新命令 `review-input <章号> [--draft=]`(落 `工作区/审稿输入.json`)与 `save-review <章号> --file=`
+- [ ] 2.5 `finalize <章号> --payload=`:读 JSON → `finalizeChapter` → 报 commit 短哈希与下一步
+- [ ] 2.6 逐命令测试:正常路径 + 文件缺失/JSON 坏/schema 不过的人话报错与退出码;`--file` 中文内容往返
+- [ ] 2.7 集成测试(D2 出口):`test/integration/` 子进程 spawn bin 跑通 建书→细纲→草稿→机检→review-input→save-review(桩JSON)→finalize→`next --json` 报第 2 章——主循环全程 CLI
+
+## 3. 安装器(PRD A)
+
+- [ ] 3.1 `adapters/registry.json` 每宿主加 `detect_bin` / `install_dir`;`src/host-shells/validator.js` 同步校验;跑 drift check 确认不破
+- [ ] 3.2 `src/installer/detect.js`:PATH 探测(注入 env 可测,win32 PATHEXT);`--hosts` 覆盖解析
+- [ ] 3.3 `src/installer/vendor.js`:包根定位 + `.webnovel/` 复制清单(bin/src/roles/package.json + js-yaml 目录,design §1.2);books.jsonl 只建不覆盖
+- [ ] 3.4 `src/installer/manifest.js`:sha256 清单读写 + update 三态判定(AGENTS.md 记块内哈希)
+- [ ] 3.5 `src/installer/shells.js`:generateHostShells 产物平移进 `install_dir`;claude-code settings.json SessionStart 幂等合并(不进清单)
+- [ ] 3.6 `src/installer/index.js` 编排 init(检测→布局→壳→AGENTS.md→manifest→报告)与 update(哈希三态 + `--force` + AGENTS.md 块内更新 + settings 幂等合并 + vendored 内自更新提示);`src/commands/init.js` / `update.js` 薄壳
+- [ ] 3.7 测试:init 布局逐项断言(AC2)、重复 init=update 语义、update 三态(AC3)、未检测到宿主→公约数层 + 指引、`.webnovel/` 里的 bin 可 `node` 直跑(spawn 验证 vendored 自包含)
+
+## 4. 壳接线(PRD A4/B4)
+
+- [ ] 4.1 生成器上下文加 `cmd`;SKILL.md 全部命令引用改 `{{cmd}}`;写章流程接 F1 通道(`review-input`→两审→`save-review`→`finalize`,「继续」= `next --json`)
+- [ ] 4.2 `node scripts/build-host-shells.mjs --check` 绿;host-shells 既有测试更新(文案级断言按需改——测试是探针不是约束)
+
+## 5. 发布产物与 CI(PRD D)
+
+- [ ] 5.1 `package.json` 加 `files` 白名单(bin/src/roles/skills/adapters/templates);`npm pack --dry-run` 核对清单
+- [ ] 5.2 CI(v7-ci.yml)加安装链路 job:npm pack → 干净**中文路径**临时目录 npm install 产物 → init → persist-book 建第一本书 → 布局断言 → `next --json`;ubuntu + windows 双平台(AC1 CI 半)
+- [ ] 5.3 本地全量:`node --test` 绿 + drift check 绿 + pack e2e 本地演练
+
+## 6. 收尾
+
+- [ ] 6.1 `--help` 增新命令段;bin 帮助文案与实际命令清单一致性测试(如已有惯例则从之)
+- [ ] 6.2 手测(AC1 手测半,用户执行):Windows 中文用户名真机 npx(pack 产物)init → 建书 → 写一章走到定稿;记录进任务 notes
+- [ ] 6.3 spec 更新(3.3):实施计划 M5 打勾与偏差记录;registry/support.md 如有字段变化同步;memory 更新
+- [ ] 6.4 提交(3.4):分批 commit(基座 / F1 / 安装器 / 壳与 CI)
+
+## V. 验证命令
+
+```bash
+cd v7
+node --test                                # 全量(含新增)
+node scripts/build-host-shells.mjs --check # drift + validator
+npm pack --dry-run                         # 发布清单
+node bin/webnovel-writer.js --help         # 人话面
+```
+
+## 风险与回滚点
+
+- `bin/webnovel-writer.js`:所有命令的入口,改坏波及全部——第 1 步单独 commit,作为回滚锚
+- `skills/webnovel-writer/SKILL.md` + `registry.json`:与 drift check/validator 联动,改动必须同轮跑 §V 第二条
+- `src/review/index.js` 抽函数:对外签名不变,review 既有测试是守门
+- Windows 本地跑 node 测试无需 PYTHONUTF8(那是 pytest 的坑);spawn 子进程一律 args 数组不走 shell
+- 回滚:各步独立 commit,`git revert` 单步可退;安装产物层面 init/update 幂等,重跑即修复

+ 80 - 0
.trellis/tasks/07-03-m5-installer/prd.md

@@ -0,0 +1,80 @@
+# M5 安装器与多本书
+
+## Goal
+
+作者在干净环境(含 Windows 中文用户名路径)一条命令装出可用的工作目录,建起第一本书;写章八阶段的每一步都有宿主可调的 CLI 通道,`finalize→next` 主循环可全程经 CLI 跑通。
+
+对应实施计划 0.3 §2「M5 安装器与多本书」,上游依据:`multi-agent-adaptation-spec` v3.5 §8(init/update)、`story-repo-spec` 0.9 §2.0(工作目录层)、M1-M4 review follow-up F1(宿主 CLI 缝清单)。
+
+## Background(已确认事实)
+
+- M0-M4 已交付:格式层 41 读接口、写章脚本面(prepare-chapter/mechanical-check)、状态机 7 态 + git 隐身、AI 角色层 + 壳生成器(`src/host-shells/generate.js` + `scripts/build-host-shells.mjs --check` 已进 CI)。边界收口任务已归档,292 测试绿。
+- `src/installer/index.js` 是占位空文件。
+- **落盘用例已齐但无 CLI 通道**:`persistDraftOutline` / `persistCreateBook` / `persistVolumeReview` / `persistRepair`(`src/state-machine/persist.js`)、`assembleReviewInput` / `runReviews`(`src/review/index.js`)、`finalizeChapter`(`src/finalize/index.js`)只能进程内调用——真宿主(AI 会话)无法驱动写章后半段,M4 真模型 smoke 因此推迟 beta(registry `smoke_status: deferred-beta`)。
+- **工作目录定位未做**:`bin/webnovel-writer.js` 写死 `repoPath = process.cwd()`(M1 design 注释「M3 后续处理」,M3 未做)。装出的工作目录里跑 CLI 会把 `.cache` 建在工作目录而非书仓库,M5 必须先解决。
+- `books.jsonl` 读侧 + 扫描重建自愈已在 M4(`src/session/index.js`,含 `writeBooksRegistry` 自愈回写);**写侧(建书登记/换书)归 M5**(源码注释明示)。
+- 工作目录 `AGENTS.md` 模板已有(`v7/templates/AGENTS.md`,含 `<!-- WEBNOVEL:START/END -->` 标记块);书仓库指路 `AGENTS.md`(spec §2.1「建书时自动生成」)尚无实现——`persistCreateBook` 不写它。
+- 壳生成器产物形状:`{ <host>: { 'skills/webnovel-writer/SKILL.md', 'agents/<角色>.md|.toml' } }`,无 hook 接线;claude-code 的 SessionStart hook 注入(multi-agent spec §5.5)需要 M5 在装出的 `.claude/` 里接线。
+- SKILL.md 当前硬编码 `webnovel-writer <命令>` 调用形式;spec §5.9 要求命令引用语法做成平台上下文变量。
+- Node 版本门槛(≥ 22.13.0)与人话提示已在 `src/runtime/node-version.js`,bin 已接。
+- 运行时依赖仅 `js-yaml`(spec §2.2),`.webnovel/` 自带脚本时需一并解决其可用性。
+- CI 现状(`.github/workflows/v7-ci.yml`):双平台 × 双 Node 版本,`node --test` + drift check + `--version` 冒烟;无安装器链路用例。
+
+## Requirements
+
+### A. 安装器 `init` / `update`(multi-agent spec §8)
+
+- A1 `init`:环境检测(Node 门槛复用现有函数;按 registry 顺序探测已装 agent CLI)→ 生成工作目录布局:
+  - `AGENTS.md`(公约数层,标记块管理;块外用户内容保留)
+  - `.webnovel/`(Node 脚本 + 角色定义 + 模板哈希清单 + `books.jsonl` 占位)——工作目录自包含,脚本离线可跑
+  - 检测到的各平台壳(`.claude/`、`.codex/` 等,由 M4 生成器按条件块编译;claude-code 含 SessionStart hook 接线)
+  - 结束输出报告:装到了哪、各宿主支持等级、降级说明、下一步人话指引
+- A2 `update`:模板哈希追踪——哈希未变的文件直接更新;用户改过的提示并跳过(不静默覆盖),非交互默认跳过并列清单,`--force` 显式覆盖;`AGENTS.md` 只更新标记块内内容
+- A3 边界(spec §8.3):不装 Node 之外的运行时、不改用户全局配置、不联网、不把工作目录变成 git 仓库;重复 `init` 幂等(等价于 update 语义或明确提示)
+- A4 平台壳 SKILL.md 的命令引用语法改为模板变量(§5.9),指向装出的 `.webnovel/` 内脚本;SKILL.md 写章流程同步接 F1 通道(`review-input`→两审→`save-review`→`finalize`,「继续」吃 `next --json` DTO)
+
+### B. 多本书与工作目录定位(story-repo spec §2.0)
+
+- B1 `books.jsonl` 写侧:建书登记(字段含 书名/目录/当前/最后打开)、换书(改「当前」标记)、书单列出;与 M4 读侧/自愈共用一个模块,不双写格式
+- B2 工作目录定位:bin 启动时判定——cwd 含 `book.yaml` → 书仓库直启(兼容开发/测试);cwd 含 `.webnovel/` → 按 `books.jsonl` 当前书解析 `repoPath`,无当前书给人话指引;两者都不是 → 人话提示「请从工作目录启动」。`init` 等工作目录层命令不受此约束
+- B3 书仓库指路 `AGENTS.md`:并入建书落盘(`persistCreateBook`),内容按 spec §2.1(误启动/单独 clone 时指回工作目录)
+- B4 SessionStart 注入接线:claude-code 壳带 hook 配置调 CLI 的会话上下文命令(输出复用 M4 `assembleSessionContext`,两宿主路径注入逐字一致)
+
+### C. F1 宿主 CLI 缝(实施计划 0.3 M5 清单,逐条)
+
+- C1 `next --json`:输出完整状态机 DTO(`{ok, gitHealth, 序, state, needsAI, message, dto}`);不带 flag 保留现有人读 message
+- C2 `review-input <章号>`:组装 ReviewInput 并落盘供宿主读取
+- C3 `save-review <章号> --file=<两审json>`:读文件 → schema 校验 → 合并 → 落审稿单与评审报告(复用 `runReviews` 的校验/合并/落盘路径)
+- C4 `persist-outline` / `persist-book` / `persist-volume-review` / `persist-repair`:AI 态产物回流,`--file=<json>` 进;`persist-book` 同时完成 books.jsonl 登记 + 指路 AGENTS.md(B1/B3)
+- C5 `finalize <章号> --payload=<定稿包json路径>`:调 `finalizeChapter`,定稿后刷新缓存
+- C6 JSON 一律走 `--file`/`--payload` 文件路径,不走 stdin(Windows 中文管道编码雷区);出错人话报错、退出码非零、永不带栈崩溃
+
+### D. CI 与验收链路
+
+- D1 Windows 中文路径安装链路 CI:干净临时目录(路径含中文,模拟中文用户名)→ 一条命令 init → 建书(经 CLI)→ 布局断言(beta 判据「Windows 中文路径全链路」的 M5 补全)
+- D2 `finalize→next` 端到端经 CLI(子进程 spawn bin,非进程内调用)跑通:细纲→草稿→机检→审稿→定稿→next 报下一章
+
+## Acceptance Criteria
+
+- [ ] AC1 干净 Windows 中文用户名环境一条命令装出工作目录并建第一本书:CI 用例绿(npm pack 产物 + 中文路径临时目录全链路)+ 手测一次通过
+- [ ] AC2 `init` 装出的布局逐项存在且内容正确:`AGENTS.md`(标记块)、`.webnovel/`(脚本可 `node` 直跑、`books.jsonl`、哈希清单)、检测到的平台壳;报告输出含支持等级与下一步指引
+- [ ] AC3 `update`:未改文件被更新;手改文件被跳过且列出;`AGENTS.md` 块外内容保留;`--force` 可覆盖
+- [ ] AC4 写章八阶段每一步都有宿主可调 CLI 通道:F1 清单 8 个命令逐个有测试(含 schema 校验失败、文件缺失的人话报错)
+- [ ] AC5 `finalize→next` 端到端经 CLI 子进程跑通,next 报「起草第 N+1 章」不重抄
+- [ ] AC6 工作目录定位三分支(书仓库直启 / 工作目录+当前书 / 无处可依)各有测试;换书后 next 作用于新书
+- [ ] AC7 SessionStart 注入:hook 路径与无 hook 宿主状态机入口路径输出逐字一致(既有测试延伸到 CLI 通道)
+- [ ] AC8 全量测试绿 + CI 双平台绿;drift check 对新增模板变量仍确定性通过
+
+## Out of Scope
+
+- 真实 npm 发布——「一条命令」验收用 npm pack 产物在 CI/手测模拟(与 npx 消费同一份包内容),真发 alpha 推迟 beta 入口:alpha 阶段没有外部用户,提前发包只引来不完整版本的使用(决策 D-1,start 评审可推翻)。随之推迟的发版联动:升 7.0.0、README 版本表 / marketplace / CHANGELOG 同步(版本 CI 不查 v7/package.json,保持 7.0.0-alpha 无风险)
+- 真模型 smoke(Claude Code / Codex 亲测)——推迟 beta(registry 已记 `deferred-beta`),M5 只负责让它可行
+- M5.5 体检统计项(高频意象/句式/文体指纹)
+- M6 自动模式、M7 导出与 /migrate
+- 二级宿主(gemini-cli / cursor)的实测核验——壳照常生成,支持等级按 registry 如实标注
+
+## 规划决策(用户暂离,按推荐采纳;start 评审时可推翻)
+
+- D-1 发布形态:npm pack 模拟,真发推迟 beta(见 Out of Scope 首条)
+- D-2 检测缺省:init 未检测到任何 agent CLI → 只装公约数层(AGENTS.md + `.webnovel/`)并在报告指引 `--hosts` 补装——忠于 spec「按检测生成」,不掩盖探测失败
+- D-3 任务结构:单任务推进,沿 M1-M4 先例;三块交付物(基座与多本书 / F1 缝 / 安装器)在 implement.md 里按依赖序分步、分批 commit,可独立回滚

+ 26 - 0
.trellis/tasks/07-03-m5-installer/task.json

@@ -0,0 +1,26 @@
+{
+  "id": "m5-installer",
+  "name": "m5-installer",
+  "title": "M5 安装器与多本书",
+  "description": "",
+  "status": "in_progress",
+  "dev_type": null,
+  "scope": null,
+  "package": null,
+  "priority": "P2",
+  "creator": "codex",
+  "assignee": "codex",
+  "createdAt": "2026-07-03",
+  "completedAt": null,
+  "branch": null,
+  "base_branch": "v7",
+  "worktree_path": null,
+  "commit": null,
+  "pr_url": null,
+  "subtasks": [],
+  "children": [],
+  "parent": null,
+  "relatedFiles": [],
+  "notes": "",
+  "meta": {}
+}

+ 27 - 9
v7/bin/webnovel-writer.js

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
 import { readFileSync } from 'node:fs'
 import { CacheManager } from '../src/cache/index.js'
 import { checkNodeVersion } from '../src/runtime/node-version.js'
+import { resolveRunContext } from '../src/runtime/locate.js'
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url))
 
@@ -80,18 +81,35 @@ try {
   const mod = await import(commandUrl)
 
   const { options, positionalArgs } = parseArgs(argv.slice(1))
+  const packageRoot = path.join(__dirname, '..')
 
-  const repoPath = process.cwd() // M3 状态机后续处理工作目录定位
-  cache = new CacheManager(path.join(repoPath, '.cache', 'index.db'))
-  await cache.ensureReady(repoPath)
+  // 工作目录定位(story-repo-spec §2.0):书仓库直启 / 工作目录当前书 / 人话提示
+  const plan = await resolveRunContext(process.cwd(), { scope: mod.scope || 'book' })
 
-  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)
+  if (plan.mode === 'error' || (plan.mode === 'workdir-no-book' && !mod.allowNoBook)) {
+    console.error(plan.message)
     process.exitCode = 1
+  } else {
+    let ctx
+    if (plan.mode === 'workdir') {
+      ctx = { workdir: plan.workdir, packageRoot }
+    } else if (plan.mode === 'workdir-no-book') {
+      // 空工作目录里允许跑的命令(next → 状态机报序1 建书引导)
+      ctx = { workdir: plan.workdir, packageRoot, repoPath: null, cache: null }
+    } else {
+      cache = new CacheManager(path.join(plan.repoPath, '.cache', 'index.db'))
+      await cache.ensureReady(plan.repoPath)
+      ctx = { repoPath: plan.repoPath, cache, workdir: plan.workdir ?? null, packageRoot }
+    }
+
+    const result = await mod.run(positionalArgs, options, ctx)
+    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') {

+ 21 - 0
v7/src/commands/list-books.js

@@ -0,0 +1,21 @@
+import { loadBooks } from '../session/index.js'
+
+/**
+ * list-books:书单(当前标记/目录/最后打开)。登记缺失时触发扫描重建自愈。
+ * 契约:纯返回 {ok, output?, error?}。
+ */
+export const scope = 'workdir'
+
+export async function run(args, options, ctx) {
+  const { books, rebuilt, needsAuthorPick } = await loadBooks(ctx.workdir)
+  if (!books.length) {
+    return { ok: true, output: '工作目录还没有书。对 AI 说「建书」开始第一本。' }
+  }
+  const lines = books.map(
+    (b) =>
+      `${b.当前 ? '→' : ' '} 《${b.书名}》  目录:${b.目录}${b.最后打开 ? `  最后打开:${b.最后打开}` : ''}`
+  )
+  if (rebuilt) lines.push('(书单刚按目录扫描重建)')
+  if (needsAuthorPick) lines.push('尚未选择当前书:运行 webnovel-writer switch-book <书名>')
+  return { ok: true, output: lines.join('\n') }
+}

+ 7 - 1
v7/src/commands/next.js

@@ -1,11 +1,17 @@
 import { determineNextState } from '../state-machine/index.js'
 
 /**
- * next(「继续」单入口):跑状态机判定下一步,打印中文摘要。
+ * next(「继续」单入口):跑状态机判定下一步。缺省打印中文摘要;--json 输出完整 DTO(F1)。
+ * 空工作目录(无当前书)也可跑 → 状态机报序1 建书引导。
  * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
  */
+export const allowNoBook = true
+
 export async function run(args, options, ctx) {
   const r = await determineNextState(ctx)
+  if (options.json) {
+    return { ok: true, output: JSON.stringify(r, null, 2) }
+  }
   const lines = []
   if (r.gitHealth.fixed.length) {
     lines.push('【git 已自动处理】', ...r.gitHealth.fixed.map((s) => '  · ' + s))

+ 14 - 0
v7/src/commands/session-context.js

@@ -0,0 +1,14 @@
+import { assembleSessionContext, touchLastOpened } from '../session/index.js'
+
+/**
+ * session-context:输出 SessionStart 注入文本(当前在写哪本/共几本/入口)。
+ * Claude Code SessionStart hook 与无 hook 宿主状态机入口都调这里 → 注入逐字一致。
+ * 契约:纯返回 {ok, output?, error?}。
+ */
+export const scope = 'workdir'
+
+export async function run(args, options, ctx) {
+  const r = await assembleSessionContext(ctx.workdir)
+  if (r.current) await touchLastOpened(ctx.workdir, r.current.目录)
+  return { ok: true, output: r.text }
+}

+ 15 - 0
v7/src/commands/switch-book.js

@@ -0,0 +1,15 @@
+import { setCurrentBook } from '../session/index.js'
+
+/**
+ * switch-book <书名或目录名>:换书=改 books.jsonl 的「当前」标记。未命中列候选。
+ * 契约:纯返回 {ok, output?, error?}。
+ */
+export const scope = 'workdir'
+
+export async function run(args, options, ctx) {
+  const name = args[0]
+  if (!name) return { ok: false, error: '用法:webnovel-writer switch-book <书名或目录名>' }
+  const r = await setCurrentBook(ctx.workdir, name)
+  if (!r.ok) return { ok: false, error: r.error }
+  return { ok: true, output: `已切换:当前在写《${r.book.书名}》(目录:${r.book.目录})` }
+}

+ 74 - 0
v7/src/runtime/locate.js

@@ -0,0 +1,74 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { loadBooks } from '../session/index.js'
+
+/**
+ * 工作目录定位(story-repo-spec §2.0,M5 B2)。只判定,不建缓存——bin 按返回的 plan 组 ctx。
+ * 三分支:书仓库直启(兼容开发/测试)/ 工作目录按当前书解析 / 无处可依人话提示。
+ *
+ * scope(命令模块可选导出,缺省 'book'):
+ * - 'book'            需要书仓库(repoPath + cache)
+ * - 'workdir'         需要工作目录(.webnovel/ 存在)
+ * - 'workdir-or-book' 书仓库直启优先,否则工作目录(persist-book:建新书时书目录还不存在)
+ * - 'anywhere'        当前目录即工作目标,自行校验(init:装出 .webnovel/ 之前就要能跑)
+ *
+ * @returns {Promise<
+ *   | {mode:'book', repoPath:string, workdir:null}
+ *   | {mode:'workdir', workdir:string}
+ *   | {mode:'workdir-book', workdir:string, repoPath:string, book:object}
+ *   | {mode:'workdir-no-book', workdir:string, message:string}
+ *   | {mode:'error', message:string}>}
+ */
+export async function resolveRunContext(cwd, { scope = 'book' } = {}) {
+  if (scope === 'anywhere') return { mode: 'workdir', workdir: cwd }
+
+  const hasBookYaml = await exists(path.join(cwd, 'book.yaml'))
+  const hasWebnovel = await exists(path.join(cwd, '.webnovel'))
+
+  if (scope === 'workdir') {
+    if (!hasWebnovel) return { mode: 'error', message: NOT_WORKDIR }
+    return { mode: 'workdir', workdir: cwd }
+  }
+
+  if (scope === 'workdir-or-book') {
+    if (hasBookYaml) return { mode: 'book', repoPath: cwd, workdir: null }
+    if (hasWebnovel) return { mode: 'workdir', workdir: cwd }
+    return { mode: 'error', message: NOWHERE }
+  }
+
+  // scope === 'book'
+  if (hasBookYaml) return { mode: 'book', repoPath: cwd, workdir: null }
+  if (hasWebnovel) {
+    const { books } = await loadBooks(cwd)
+    const current = books.find((b) => b.当前)
+    if (!current) {
+      const message = books.length
+        ? `尚未选择当前书(候选:${books.map((b) => b.书名).join('、')})。运行 switch-book <书名> 选一本。`
+        : '工作目录还没有书。对 AI 说「建书」开始第一本。'
+      return { mode: 'workdir-no-book', workdir: cwd, message }
+    }
+    const repoPath = path.join(cwd, current.目录)
+    if (!(await exists(path.join(repoPath, 'book.yaml')))) {
+      return {
+        mode: 'workdir-no-book',
+        workdir: cwd,
+        message: `登记的当前书《${current.书名}》在 ${current.目录}/ 里找不到 book.yaml。运行 list-books 核对书单,或 switch-book 换一本。`,
+      }
+    }
+    return { mode: 'workdir-book', workdir: cwd, repoPath, book: current }
+  }
+  return { mode: 'error', message: NOWHERE }
+}
+
+const NOT_WORKDIR = '这里不是 webnovel-writer 工作目录(缺 .webnovel/)。请到装好的工作目录里运行;还没安装先跑 webnovel-writer init。'
+const NOWHERE =
+  '请从工作目录启动(目录下应有 .webnovel/),或在书仓库根目录(含 book.yaml)运行。还没安装先跑 webnovel-writer init。'
+
+async function exists(p) {
+  try {
+    await fs.access(p)
+    return true
+  } catch {
+    return false
+  }
+}

+ 77 - 13
v7/src/session/index.js

@@ -3,9 +3,9 @@ import path from 'node:path'
 import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
 
 /**
- * SessionStart 注入与书单自愈(story-repo-spec §2.0)。
+ * SessionStart 注入与书单登记(story-repo-spec §2.0)。
  * 有 hook 宿主(Claude Code)启动调本层;无 hook 宿主由状态机入口调同一函数,行为等价。
- * 写侧(books.jsonl 登记/换书)属 M5;本层只读 + 扫描重建
+ * 读侧 + 扫描重建自愈(M4)与写侧(登记/换书/最后打开,M5)同模块,books.jsonl 格式单源
  */
 
 /** 读 .webnovel/books.jsonl,逐行 JSON,损坏行跳过并计数 */
@@ -33,8 +33,7 @@ export async function readBooksRegistry(workdir) {
 
 /**
  * 自愈回写:把有效书单写回 .webnovel/books.jsonl(P1-4)。
- * 仅用于丢坏行 / 落扫描重建结果,不是 M5 的登记/换书写侧——那归 M5。
- * 失败不阻断会话(best-effort)。
+ * 失败不阻断会话(best-effort 由调用方决定)。
  */
 export async function writeBooksRegistry(workdir, books) {
   const dir = path.join(workdir, '.webnovel')
@@ -64,14 +63,14 @@ export async function scanRebuildBooks(workdir) {
 }
 
 /**
- * 组装 SessionStart 注入文本(当前在写哪本/共几本/全书近况入口)
- * 登记缺失或为空 → 扫描重建。两个宿主入口调本函数 → 注入逐字一致
+ * 读书单并自愈:登记缺失/为空 → 扫描重建回写;坏行 → 丢弃回写好行
+ * SessionStart 注入、工作目录定位、list-books 共用本函数,自愈逻辑单源
  */
-export async function assembleSessionContext(workdir) {
+export async function loadBooks(workdir) {
   let reg = await readBooksRegistry(workdir)
   let rebuilt = false
   let needsAuthorPick = false
-  let toWrite = null // P1-4:自愈回写载体
+  let toWrite = null
 
   if (!reg.ok || reg.missing || reg.books.length === 0) {
     const scan = await scanRebuildBooks(workdir)
@@ -92,17 +91,82 @@ export async function assembleSessionContext(workdir) {
       // 回写失败不阻断会话(best-effort)
     }
   }
+  return { ok: true, books: reg.books, rebuilt, needsAuthorPick }
+}
+
+/** 本地日期 YYYY-MM-DD(「最后打开」是作者可见字段,取本地时区) */
+function localDate() {
+  const d = new Date()
+  const p = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
+}
+
+/**
+ * 登记一本书并置为当前(建书流程调用)。同目录已登记则更新书名,不产生重复行。
+ */
+export async function registerBook(workdir, { 书名, 目录 }) {
+  if (!书名 || !目录) return { ok: false, error: '登记需要 书名 与 目录', books: [] }
+  const { books } = await loadBooks(workdir)
+  const kept = books.filter((b) => b.目录 !== 目录).map((b) => ({ ...b, 当前: false }))
+  kept.push({ 书名, 目录, 当前: true, 最后打开: localDate() })
+  try {
+    await writeBooksRegistry(workdir, kept)
+  } catch (err) {
+    return { ok: false, error: `写书单失败:${err.message}`, books: [] }
+  }
+  return { ok: true, books: kept, error: '' }
+}
+
+/**
+ * 换书:按书名或目录名精确匹配置「当前」。未命中返回候选,人话交作者。
+ */
+export async function setCurrentBook(workdir, name) {
+  const { books } = await loadBooks(workdir)
+  const hit = books.find((b) => b.书名 === name || b.目录 === name)
+  if (!hit) {
+    const 候选 = books.map((b) => b.书名).join('、') || '(书单为空)'
+    return { ok: false, book: null, error: `没有叫《${name}》的书。候选:${候选}` }
+  }
+  const next = books.map((b) =>
+    b === hit ? { ...b, 当前: true, 最后打开: localDate() } : { ...b, 当前: false }
+  )
+  try {
+    await writeBooksRegistry(workdir, next)
+  } catch (err) {
+    return { ok: false, book: null, error: `写书单失败:${err.message}` }
+  }
+  return { ok: true, book: next.find((b) => b.当前), error: '' }
+}
+
+/** 会话打开当前书时刷新「最后打开」。best-effort,失败不阻断 */
+export async function touchLastOpened(workdir, 目录) {
+  try {
+    const reg = await readBooksRegistry(workdir)
+    if (!reg.ok || reg.missing) return
+    const next = reg.books.map((b) => (b.目录 === 目录 ? { ...b, 最后打开: localDate() } : b))
+    await writeBooksRegistry(workdir, next)
+  } catch {
+    // best-effort
+  }
+}
+
+/**
+ * 组装 SessionStart 注入文本(当前在写哪本/共几本/全书近况入口)。
+ * 登记缺失或为空 → 扫描重建。两个宿主入口调本函数 → 注入逐字一致。
+ */
+export async function assembleSessionContext(workdir) {
+  const { books, rebuilt, needsAuthorPick } = await loadBooks(workdir)
 
-  const current = reg.books.find((b) => b.当前) || null
-  const names = reg.books.map((b) => b.书名).join('、')
+  const current = books.find((b) => b.当前) || null
+  const names = books.map((b) => b.书名).join('、')
   const text = [
     current
       ? `当前在写《${current.书名}》`
-      : reg.books.length
+      : books.length
         ? `尚未选择当前书(候选:${names})`
         : '尚未选择当前书',
-    `共 ${reg.books.length} 本`,
+    `共 ${books.length} 本`,
     current ? '继续写作直接说「继续」(将读当前书全书近况)' : '请选择要写哪本书',
   ].join(';')
-  return { ok: true, text, books: reg.books, current, rebuilt, needsAuthorPick }
+  return { ok: true, text, books, current, rebuilt, needsAuthorPick }
 }

+ 1 - 0
v7/src/state-machine/dto.js

@@ -48,6 +48,7 @@ export async function buildDto(ctx, 序, base = {}) {
 }
 
 async function whatsMissing(ctx) {
+  if (!ctx.repoPath) return ['book.yaml', '总纲'] // 空工作目录:书仓库还不存在
   const missing = []
   for (const [label, rel] of [
     ['book.yaml', 'book.yaml'],

+ 6 - 0
v7/src/state-machine/index.js

@@ -11,6 +11,12 @@ import * as d from './detectors.js'
  */
 export async function determineNextState(ctx) {
   const { repoPath, cache } = ctx
+
+  // 空工作目录(bin 定位后无当前书):没有书仓库可查,直接序1 建书引导(spec §10 序1「工作目录无任何书」)
+  if (!repoPath) {
+    return mk(1, 'create-book', true, '工作目录还没有书,进入建书引导。', { fixed: [], guidance: [] }, await buildDto(ctx, 1, {}))
+  }
+
   const gitHealth = await checkGitHealth(ctx)
 
   // 序0 修复确认(检测=脚本,提议=AI)

+ 87 - 0
v7/test/commands/books-commands.test.js

@@ -0,0 +1,87 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import os from 'node:os'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { run as listBooks } from '../../src/commands/list-books.js'
+import { run as switchBook } from '../../src/commands/switch-book.js'
+import { run as sessionContext } from '../../src/commands/session-context.js'
+import { assembleSessionContext, readBooksRegistry } from '../../src/session/index.js'
+
+async function tmpWorkdir(books = null) {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-bkcmd-'))
+  if (books) {
+    await fs.mkdir(path.join(root, '.webnovel'), { recursive: true })
+    await fs.writeFile(
+      path.join(root, '.webnovel', 'books.jsonl'),
+      books.map((b) => JSON.stringify(b)).join('\n') + '\n',
+      'utf8'
+    )
+  }
+  return { root, ctx: { workdir: root }, cleanup: () => fs.rm(root, { recursive: true, force: true }) }
+}
+
+test('list-books:列书单,当前书带标记', async () => {
+  const { ctx, cleanup } = await tmpWorkdir([
+    { 书名: '星海', 目录: '星海', 当前: true, 最后打开: '2026-07-01' },
+    { 书名: '剑起青云', 目录: '剑起青云', 当前: false },
+  ])
+  try {
+    const r = await listBooks([], {}, ctx)
+    assert.equal(r.ok, true)
+    assert.ok(r.output.includes('《星海》') && r.output.includes('《剑起青云》'))
+    assert.ok(r.output.includes('2026-07-01'))
+    const cur = r.output.split('\n').find((l) => l.startsWith('→'))
+    assert.ok(cur.includes('星海'), '当前书行应带 → 标记')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('list-books:空书单 → 建书指引', async () => {
+  const { root, ctx, cleanup } = await tmpWorkdir(null)
+  try {
+    await fs.mkdir(path.join(root, '.webnovel'), { recursive: true })
+    const r = await listBooks([], {}, ctx)
+    assert.equal(r.ok, true)
+    assert.ok(r.output.includes('建书'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('switch-book:换书改当前标记;缺参数人话报错', async () => {
+  const { root, ctx, cleanup } = await tmpWorkdir([
+    { 书名: '星海', 目录: '星海', 当前: true },
+    { 书名: '剑起青云', 目录: '剑起青云', 当前: false },
+  ])
+  try {
+    const bad = await switchBook([], {}, ctx)
+    assert.equal(bad.ok, false)
+    assert.ok(bad.error.includes('用法'))
+
+    const r = await switchBook(['剑起青云'], {}, ctx)
+    assert.equal(r.ok, true)
+    assert.ok(r.output.includes('剑起青云'))
+    const reg = await readBooksRegistry(root)
+    assert.equal(reg.books.find((b) => b.当前).书名, '剑起青云')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('session-context:输出与 assembleSessionContext 逐字一致(两宿主入口等价),并刷新最后打开', async () => {
+  const { root, ctx, cleanup } = await tmpWorkdir([
+    { 书名: '星海', 目录: '星海', 当前: true, 最后打开: '2000-01-01' },
+  ])
+  try {
+    const expected = (await assembleSessionContext(root)).text
+    const r = await sessionContext([], {}, ctx)
+    assert.equal(r.ok, true)
+    assert.equal(r.output, expected)
+    const reg = await readBooksRegistry(root)
+    assert.notEqual(reg.books[0].最后打开, '2000-01-01')
+  } finally {
+    await cleanup()
+  }
+})

+ 142 - 0
v7/test/runtime/locate.test.js

@@ -0,0 +1,142 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import os from 'node:os'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { resolveRunContext } from '../../src/runtime/locate.js'
+
+async function tmpDir() {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-loc-'))
+  return { root, cleanup: () => fs.rm(root, { recursive: true, force: true }) }
+}
+async function makeWorkdir(root, books = []) {
+  await fs.mkdir(path.join(root, '.webnovel'), { recursive: true })
+  await fs.writeFile(
+    path.join(root, '.webnovel', 'books.jsonl'),
+    books.map((b) => JSON.stringify(b)).join('\n') + '\n',
+    'utf8'
+  )
+}
+async function makeBook(root, name) {
+  await fs.mkdir(path.join(root, name), { recursive: true })
+  await fs.writeFile(path.join(root, name, 'book.yaml'), `spec_version: "7.0"\n书名: ${name}\n`, 'utf8')
+}
+
+test('locate:cwd 含 book.yaml → 书仓库直启(兼容开发/测试)', async () => {
+  const { root, cleanup } = await tmpDir()
+  try {
+    await fs.writeFile(path.join(root, 'book.yaml'), '书名: 测\n', 'utf8')
+    const p = await resolveRunContext(root)
+    assert.equal(p.mode, 'book')
+    assert.equal(p.repoPath, root)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('locate:工作目录 + 当前书 → workdir-book,repoPath 指向书目录', async () => {
+  const { root, cleanup } = await tmpDir()
+  try {
+    await makeBook(root, '剑起青云')
+    await makeWorkdir(root, [{ 书名: '剑起青云', 目录: '剑起青云', 当前: true }])
+    const p = await resolveRunContext(root)
+    assert.equal(p.mode, 'workdir-book')
+    assert.equal(p.repoPath, path.join(root, '剑起青云'))
+    assert.equal(p.book.书名, '剑起青云')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('locate:工作目录无任何书 → workdir-no-book + 建书指引', async () => {
+  const { root, cleanup } = await tmpDir()
+  try {
+    await makeWorkdir(root, [])
+    const p = await resolveRunContext(root)
+    assert.equal(p.mode, 'workdir-no-book')
+    assert.ok(p.message.includes('建书'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('locate:有书但未选当前 → workdir-no-book + 候选与 switch-book 指引', async () => {
+  const { root, cleanup } = await tmpDir()
+  try {
+    await makeBook(root, '星海')
+    await makeWorkdir(root, [{ 书名: '星海', 目录: '星海', 当前: false }])
+    const p = await resolveRunContext(root)
+    assert.equal(p.mode, 'workdir-no-book')
+    assert.ok(p.message.includes('星海') && p.message.includes('switch-book'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('locate:登记的当前书目录缺 book.yaml → workdir-no-book + 核对指引', async () => {
+  const { root, cleanup } = await tmpDir()
+  try {
+    await makeWorkdir(root, [{ 书名: '幽灵书', 目录: '不存在', 当前: true }])
+    const p = await resolveRunContext(root)
+    assert.equal(p.mode, 'workdir-no-book')
+    assert.ok(p.message.includes('幽灵书'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('locate:既非工作目录也非书仓库 → error 人话提示', async () => {
+  const { root, cleanup } = await tmpDir()
+  try {
+    const p = await resolveRunContext(root)
+    assert.equal(p.mode, 'error')
+    assert.ok(p.message.includes('工作目录'))
+    assert.ok(!/[A-Za-z]:\\/.test(p.message), '不暴露绝对路径')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('locate:scope=workdir 在非工作目录 → error;在工作目录 → workdir', async () => {
+  const { root, cleanup } = await tmpDir()
+  try {
+    assert.equal((await resolveRunContext(root, { scope: 'workdir' })).mode, 'error')
+    await makeWorkdir(root, [])
+    const p = await resolveRunContext(root, { scope: 'workdir' })
+    assert.equal(p.mode, 'workdir')
+    assert.equal(p.workdir, root)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('locate:scope=workdir-or-book 书仓库优先,退而工作目录', async () => {
+  const a = await tmpDir()
+  const b = await tmpDir()
+  try {
+    await fs.writeFile(path.join(a.root, 'book.yaml'), '书名: 测\n', 'utf8')
+    assert.equal((await resolveRunContext(a.root, { scope: 'workdir-or-book' })).mode, 'book')
+    await makeWorkdir(b.root, [])
+    assert.equal((await resolveRunContext(b.root, { scope: 'workdir-or-book' })).mode, 'workdir')
+    const empty = await tmpDir()
+    try {
+      assert.equal((await resolveRunContext(empty.root, { scope: 'workdir-or-book' })).mode, 'error')
+    } finally {
+      await empty.cleanup()
+    }
+  } finally {
+    await a.cleanup()
+    await b.cleanup()
+  }
+})
+
+test('locate:scope=anywhere 直接给 workdir(init 装出 .webnovel 之前就要能跑)', async () => {
+  const { root, cleanup } = await tmpDir()
+  try {
+    const p = await resolveRunContext(root, { scope: 'anywhere' })
+    assert.equal(p.mode, 'workdir')
+    assert.equal(p.workdir, root)
+  } finally {
+    await cleanup()
+  }
+})

+ 144 - 0
v7/test/session/registry-write.test.js

@@ -0,0 +1,144 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import os from 'node:os'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import {
+  registerBook,
+  setCurrentBook,
+  touchLastOpened,
+  readBooksRegistry,
+  loadBooks,
+} from '../../src/session/index.js'
+
+async function tmpWorkdir() {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-reg-'))
+  return { root, cleanup: () => fs.rm(root, { recursive: true, force: true }) }
+}
+async function writeRegistry(root, lines) {
+  await fs.mkdir(path.join(root, '.webnovel'), { recursive: true })
+  await fs.writeFile(path.join(root, '.webnovel', 'books.jsonl'), lines.join('\n') + '\n', 'utf8')
+}
+
+const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
+
+test('registerBook:空工作目录登记首本,置当前并带最后打开', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    const r = await registerBook(root, { 书名: '剑起青云', 目录: '剑起青云' })
+    assert.equal(r.ok, true)
+    const reg = await readBooksRegistry(root)
+    assert.equal(reg.books.length, 1)
+    assert.equal(reg.books[0].书名, '剑起青云')
+    assert.equal(reg.books[0].当前, true)
+    assert.match(reg.books[0].最后打开, DATE_RE)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('registerBook:新书置当前,旧当前退位', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await writeRegistry(root, [JSON.stringify({ 书名: '星海', 目录: '星海', 当前: true })])
+    const r = await registerBook(root, { 书名: '剑起青云', 目录: '剑起青云' })
+    assert.equal(r.ok, true)
+    const reg = await readBooksRegistry(root)
+    assert.equal(reg.books.length, 2)
+    assert.equal(reg.books.find((b) => b.书名 === '星海').当前, false)
+    assert.equal(reg.books.find((b) => b.书名 === '剑起青云').当前, true)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('registerBook:同目录重复登记不产生重复行,书名更新', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await writeRegistry(root, [JSON.stringify({ 书名: '旧名', 目录: '书目录', 当前: true })])
+    const r = await registerBook(root, { 书名: '新名', 目录: '书目录' })
+    assert.equal(r.ok, true)
+    const reg = await readBooksRegistry(root)
+    assert.equal(reg.books.length, 1)
+    assert.equal(reg.books[0].书名, '新名')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('registerBook:缺书名/目录 → 人话错误', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    const r = await registerBook(root, { 书名: '', 目录: 'x' })
+    assert.equal(r.ok, false)
+    assert.ok(r.error.includes('书名'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('setCurrentBook:按书名或目录命中,单一当前', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await writeRegistry(root, [
+      JSON.stringify({ 书名: '星海', 目录: 'xinghai', 当前: true }),
+      JSON.stringify({ 书名: '剑起青云', 目录: 'jian', 当前: false }),
+    ])
+    const r = await setCurrentBook(root, '剑起青云')
+    assert.equal(r.ok, true)
+    assert.equal(r.book.目录, 'jian')
+    const reg = await readBooksRegistry(root)
+    assert.equal(reg.books.filter((b) => b.当前).length, 1)
+    assert.equal(reg.books.find((b) => b.当前).书名, '剑起青云')
+    // 按目录名也可命中
+    const r2 = await setCurrentBook(root, 'xinghai')
+    assert.equal(r2.ok, true)
+    assert.equal(r2.book.书名, '星海')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('setCurrentBook:未命中 → 列候选', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await writeRegistry(root, [JSON.stringify({ 书名: '星海', 目录: '星海', 当前: true })])
+    const r = await setCurrentBook(root, '不存在的书')
+    assert.equal(r.ok, false)
+    assert.ok(r.error.includes('星海'), `候选应包含书名:${r.error}`)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('touchLastOpened:刷新最后打开,不改其他字段', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await writeRegistry(root, [
+      JSON.stringify({ 书名: '星海', 目录: '星海', 当前: true, 最后打开: '2000-01-01' }),
+    ])
+    await touchLastOpened(root, '星海')
+    const reg = await readBooksRegistry(root)
+    assert.notEqual(reg.books[0].最后打开, '2000-01-01')
+    assert.match(reg.books[0].最后打开, DATE_RE)
+    assert.equal(reg.books[0].当前, true)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('loadBooks:坏行丢弃回写自愈(与 assembleSessionContext 同源)', async () => {
+  const { root, cleanup } = await tmpWorkdir()
+  try {
+    await writeRegistry(root, [
+      JSON.stringify({ 书名: '星海', 目录: '星海', 当前: true }),
+      '{坏的 json',
+    ])
+    const r = await loadBooks(root)
+    assert.equal(r.books.length, 1)
+    const raw = await fs.readFile(path.join(root, '.webnovel', 'books.jsonl'), 'utf8')
+    assert.ok(!raw.includes('坏的'), '坏行应被自愈回写清除')
+  } finally {
+    await cleanup()
+  }
+})

+ 13 - 0
v7/test/state-machine/empty-workdir.test.js

@@ -0,0 +1,13 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { determineNextState } from '../../src/state-machine/index.js'
+
+test('空工作目录(repoPath=null)→ 序1 建书引导,不碰 git/缓存', async () => {
+  const r = await determineNextState({ repoPath: null, cache: null, workdir: '/tmp/任意' })
+  assert.equal(r.ok, true)
+  assert.equal(r.序, 1)
+  assert.equal(r.state, 'create-book')
+  assert.equal(r.needsAI, true)
+  assert.deepEqual(r.gitHealth, { fixed: [], guidance: [] })
+  assert.deepEqual(r.dto.缺, ['book.yaml', '总纲'])
+})