Explorar el Código

feat(core): v13 书籍创建流程迁移 — 段落式架构稿 + 旧书升级路径 + maxTokens bug fix (#207)

* chore(studio): remove dead BookFormCard create_book toolCall path

ChatPage 通过 /api/v1/agent 走的是 pi-agent loop,工具集是
sub_agent / write_truth_file / read / edit / grep / ls 等,从来不会
返回 toolCall.name === "create_book"。BookFormCard 的渲染条件因此
永远不成立,相关的 pendingBookArgs / bookCreating / handleCreateBook /
createProgress 一整套 store 状态也都是死代码。

实际建书走 BookCreate 独立表单页 → POST /api/v1/books/create,与
本次清理无关;CLI 仍在用的 develop_book / CREATE_BOOK_TOOL /
developBookDraft 全都在 core 端,未触动。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(studio): 侧边栏摘要卡改用 Streamdown 渲染 markdown

世界观/角色卡之前是 <p> 纯文本 + line-clamp,遇到 story_bible 里的
**加粗**、列表、小标题会原样显示为字面字符。现在接入 Streamdown
并带 cjk/code/math/mermaid 插件,同时用 Tailwind arbitrary variant
把标题/段落/列表的字号和间距压到卡片密度。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(studio): use-hash-route 在非 hash 页面切换时同步 React state

之前 setRoute 只在非 hash 页面分支里 setRouteState,hash 页面完全
依赖 hashchange 事件回调触发。当用户走 services → logs → services
这类路径时,中间的 logs 不写 URL,URL 一直停在 #/services;再次
赋值同一个 hash 不触发 hashchange,React state 就永远停在 logs,
表现为"点 services 没反应"。

现在进 setRoute 一律先 setRouteState,再按需写 URL,并在写 URL 前
判重避免多余 hashchange。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(core): add outline-paths helper for Phase 5 双路径 fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(core): 读架构稿硬路径统一改用 outline-paths helper

writer / continuity / reviser / chapter-analyzer / consolidator / planner /
runner / memory-retrieval 里读 story_bible.md / volume_outline.md /
character_matrix.md / current_state.md 的点都改成 readStoryFrame /
readVolumeMap / readCharacterContext / readCurrentStateWithFallback,
让 Phase 5 新书走 outline/ + roles/ 权威路径,旧书自动 fallback 到 legacy。

runner.ts 里做 before/after 对比的 old state 读取保留原样(需要磁盘原始内容);
book_rules.md 没有新路径替代,所有点也保留原 readFile 调用。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(core): architect 升级到段落式架构稿,剥离 Phase 7 伏笔扩展列

- ArchitectOutput 新增 Optional 字段 storyFrame/volumeMap/rhythmPrinciples/roles,
  并引入 ArchitectRole 类型。老字段保留让外部消费者向后兼容。
- buildChineseFoundationPrompt / buildEnglishFoundationPrompt 方法移植自 inkos-team
  的段落式 prompt(4+5 段结构 + 一人一卡 roles),剥离 Phase 7 扩展列相关要求,
  pending_hooks 保留 inkos 侧的 8 列基础版。
- parseSections 识别新 section(story_frame/volume_map/roles),legacy
  section(story_bible/volume_outline)仍被接受做 fallback;新增 parseRoles
  解析 ---ROLE--- 块。
- writeFoundationFiles 按 storyFrame 是否非空分两条路径:Phase 5 输出下产出
  outline/story_frame.md + outline/volume_map.md + roles/{主要,次要}角色/*.md +
  各个 legacy shim;legacy 输出下照老方式写扁平 md 文件。运行时 append log 文件
  (particle_ledger/subplot_board/emotional_arcs)保留初始化,兼容下游。
- extractYamlFrontmatter 顶层 helper 负责把 book_rules 的 YAML frontmatter
  拼到 story_frame.md 顶部,让读取点有单一权威位置。
- 文案:generateFoundationFromImport prompt 里 "叙事弧线" → "故事线"。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(core): state-manager 新建书籍时创建 outline/ 和 roles/ 目录

ensureControlDocumentsAt 在现有 story/ + runtime/ 之外,同时创建
story/outline/、story/roles/主要角色/、story/roles/次要角色/ 三个 Phase 5
目录。这样 initBook 完成后磁盘就有完整的 Phase 5 目录结构,
架构师产出的 outline/*.md 和 roles/**/*.md 有落脚处。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(core): 旧书架构稿升级路径 — architect revise + pipeline.reviseFoundation

Task 3.1:sub_agent 的 architect 参数新增 Optional revise + feedback 字段。
architect case 在 revise=true 时走 pipeline.reviseFoundation 分支,其他参数不变。

Task 3.2:
- architect.generateFoundation 新增第 4 个 options 参数,可带 reviseFrom 把
  老的条目式架构稿全文 + 用户反馈传进来。buildRevisePrompt 把这些拼成转换
  前缀注入到 system prompt 头部。原 3-参调用点(initBook / initFanficBook)
  保持不变。
- runner.reviseFoundation(bookId, feedback) 方法:先把 story_bible.md /
  volume_outline.md / book_rules.md / character_matrix.md 备份到
  story/.backup-phase4-<timestamp>/,读原内容喂给 architect(reviseFrom
  分支产出 Phase 5 结构),跑一次 foundation-reviewer(失败只 warn 不阻断),
  最后用 writeFoundationFiles 覆盖旧文件为 shim。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(core): context-transform 检测旧书时注入升级提示

createBookContextTransform 在读 truth files 之前先用 isNewLayoutBook(bookDir)
判断当前书的架构稿是否已经是 Phase 5 段落式。如果还是旧的条目式格式,
把一段 UPGRADE_HINT 拼到注入的 user message body 前面,提示 LLM 可以调
sub_agent(architect, { revise: true, ... }) 做一次升级——同时强调不要在
作者没明确同意前主动触发。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(test): 新测试里移除硬编码的 maxTokens / temperature 数字

此前 Phase 5 专项测试和 revise-foundation 测试按仓库历史惯例在 client.defaults
里写了 temperature: 0.7 / maxTokens: 4096 / thinkingBudget: 0 等数字。这些值:
- 对测试本身无意义——chat 方法被 vi.spyOn 拦截,defaults 运行时不被读取
- 会误导阅读者以为这是生产推荐配置,尤其 maxTokens: 4096 真被抄到生产会导致
  一章写到一半被截断(CLAUDE.md 明令禁止的 maxTokens 回归)

改成 `as unknown as LLMClient` 的 stub cast + 注释说明,保留类型满足度,
去掉所有具体数字。现有 architect.test.ts 其它测试里的老硬编码块不动,这次
只收敛新加的代码。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(core): 分离 LLMClient maxTokens (fallback) 与 maxTokensCap (cap) 语义

## Bug

createLLMClient 里 `maxTokensCap: config.maxTokens ?? null` 的实现意图是
"只有用户显式设 maxTokens 时才封顶 per-call"。但 LLMConfigSchema.maxTokens 有
zod default 8192,config.maxTokens 永远非 undefined,cap 永远等于 maxTokens。
结果 chatCompletion 里 `Math.min(perCallMax, cap)` 会把 architect 的
per-call 16384 裁到 config.maxTokens=8192,基础设定输出被截断——这正是
CLAUDE.md "防止 maxTokens 回归" 那条条款在防范的。

## 修复

把"fallback"和"cap"这两个语义彻底分到两个字段:

- `LLMConfig.maxTokens` → agent 没传 per-call 时的 fallback(不变)
- `LLMConfig.maxTokensCap` → per-call 硬上限,**新字段**,默认 undefined →
  `client.defaults.maxTokensCap = null` = 不封顶
- createLLMClient 不再从 maxTokens 推导 cap;严禁再把两者合并

## 测试

packages/core/src/__tests__/provider.test.ts 新增 "createLLMClient
maxTokensCap regression" describe:
- 只设 maxTokens → cap 为 null(不封顶)
- 显式设 maxTokensCap → cap 生效
- 什么都不设 → cap 为 null
- per-call 16384 原样到达下游,不被 config.maxTokens=8192 裁(回归核心)
- per-call 16384 被显式 maxTokensCap=4096 裁(正常 cap 语义)

## 文档

CLAUDE.md 的 "防止 maxTokens 回归" 条款改写,把两个字段的设计规则、错误
实现的样子、回归测试的路径都写清楚,让后续改动一眼看得到边界。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* revert(docs): CLAUDE.md 的 maxTokens 设计细节回退到单行索引

1f43f6a 里给 CLAUDE.md 的"防止 maxTokens 回归"条款加了 5 行
fallback/cap 字段设计细节,违反 CLAUDE.md 的渐进式披露原则
(CLAUDE.md 是 spec 索引,设计细节应该在代码注释 / 独立 spec 里)。

LLMClient 的 maxTokens / maxTokensCap 语义规则已经在
packages/core/src/llm/provider.ts 的 type definition 注释里写清楚;
回归测试在 provider.test.ts 的 "createLLMClient maxTokensCap
regression" describe block。后续改动者读到那两处即可。

CLAUDE.md 恢复到单行"防止 maxTokens 回归"的索引条款。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(core): architect writeFoundationFiles 加布局分支 debug log

新建书或 reviseFoundation 落盘时,往 logger.info 输出一行记录本次走的是
phase5 还是 legacy 分支、storyFrame/volumeMap 字符数、roles 数量。

用途:当用户发现建书后磁盘只有 story_bible.md 没有 outline/ 时,看这行
能直接判断是 LLM 没按新 prompt 输出(走 legacy 分支)还是 writeFoundationFiles
本身的问题,不用读代码排查。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(core): reviseFoundation 正确处理运行时文件 / Phase 5 二次升级 / 删除角色清理

## 三个 Bug

**1 (high) writeFoundationFiles 无条件重置运行时状态文件**
之前在 reviseFoundation 场景下会把 current_state.md / pending_hooks.md /
particle_ledger.md / subplot_board.md / emotional_arcs.md 全部重置成初始模板。
对已经写过章节的书这是灾难——consolidator 累积的伏笔推进、角色位置、资源
账本、情感曲线都被清零,违反 context-transform 给 LLM 的 upgrade hint 承诺
"升级只改架构稿,不动已写的章节"。

修复:writeFoundationFiles 加 mode: "init" | "revise" 参数。init 模式下
初始化所有运行时文件(现有行为),revise 模式下**完全不动**运行时文件。
runner.reviseFoundation 调用时传 mode="revise"。initBook / initFanficBook
用默认 mode="init" 向后兼容。

**2 (high) reviseFoundation 第二次使用时喂 shim 给 architect**
Phase 5 书的 story_bible.md / character_matrix.md 是 shim(只有指针 + 摘录),
原 reviseFoundation 无条件读这些 flat 文件作为"原内容"。第一次升级后再用
sub_agent(architect, { revise: true, ... }) 做细节调整时,架构师拿到的
"原书内容"已经是丢信息的 shim——容易把角色线、世界观细节洗掉。

修复:reviseFoundation 用 isNewLayoutBook 检测当前书状态:
- Phase 5 书 → 从 outline/story_frame.md + outline/volume_map.md +
  roles/**/*.md(走 outline-paths helper)读权威全文
- legacy 书 → 从 story_bible.md 等 flat 文件读(现有行为)

**3 (medium) 删除/改名角色后旧 role 卡残留成幽灵**
writeFoundationFiles 只写本次 architect 输出的 role 文件,不清理
roles/**/*.md 里的旧文件。如果某次 revise 改名、删人、合并角色,旧卡会继续
被 readRoleCards 注入,planner / writer / auditor 看到 ghost 角色导致上下文
污染。

修复:writeFoundationFiles 在 mode="revise" 下,写 role 前先清空
roles/主要角色/ 和 roles/次要角色/ 两个目录。备份由 runner.reviseFoundation
在调用前完成(Phase 5 书会把 outline/ + roles/ 一并备份到 .backup-phase5-
<ts>/),清空安全。

## 测试

packages/core/src/__tests__/revise-foundation.test.ts 新增 4 个回归用例:

- revise 不重置运行时状态文件(构造已写 20 章的书,验证 5 个运行时文件
  内容保持不变)
- Phase 5 书二次 revise 从权威源读(验证传给 architect 的 reviseFrom
  是完整 outline + roles 内容,不含 shim 指针)
- revise 清空旧 role 文件(构造 5 个老角色,revise 后只输出 2 个新角色,
  验证另外 3 个旧文件被删除)
- Phase 5 revise 备份带 phase5 tag 且包含 outline/ + roles/

全仓测试 785 passed(core 785 / studio 138 / cli 169)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(core): revise 模式 + legacy 输出时抛错,避免数据混乱

## Bug corner case

上一个 fix commit (81e6665) 里把 writeFoundationFiles 的 roles 清理逻辑放在
if (isPhase5Output) 分支内,漏掉了一个 corner case:revise 模式下,如果 LLM
意外回退 legacy 输出(storyFrame 为空),会走 else 分支把 LLM 产出的 legacy
内容写到 story_bible.md / volume_outline.md 等 flat 文件,但 outline/ 和
roles/ 保持不变——Phase 5 书立刻进入"outline 新 + shim 老内容 + roles 残留"
的混乱状态,读取端 fallback 读到互相矛盾的内容。

## 修法

两步:

1. 在 writeFoundationFiles 开头检查:mode === "revise" && !isPhase5Output
   → 立即抛错,不写任何文件。让用户感知 LLM 响应异常,避免数据默默被破坏。
2. roles 清理逻辑从 if (isPhase5Output) 内部移到外面,变成 mode 级行为。
   放在抛错检查之后,保证 revise 场景永远清空 roles(但抛错场景下不会执行
   到,因为先抛错了——旧 role 文件保留,加上 runner 里的 backup 双重保险)。

init 模式下 else 分支的 legacy 输出路径保持原样(老 prompt / 测试 stub 的
合法场景)。

## 测试

revise-foundation.test.ts 新增 "revise 模式 + LLM 意外输出 legacy 格式 →
抛错且不动架构稿文件" 用例:
- 构造 Phase 5 书(outline/ + roles/ 有原始内容、story_bible.md 是 shim)
- Stub architect 返回 legacy 格式输出(storyFrame 空)
- 期望 reviseFoundation 抛错含 "legacy-format output.*NOT been modified"
- 验证 outline/story_frame.md、outline/volume_map.md、story_bible.md 内容
  保持不变

全仓 786 passed / build 通过。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hanjun fang hace 6 meses
padre
commit
705555faf0
Se han modificado 32 ficheros con 2199 adiciones y 711 borrados
  1. 148 0
      packages/core/src/__tests__/architect.test.ts
  2. 26 0
      packages/core/src/__tests__/context-transform.test.ts
  3. 151 0
      packages/core/src/__tests__/outline-paths.test.ts
  4. 106 0
      packages/core/src/__tests__/provider.test.ts
  5. 514 0
      packages/core/src/__tests__/revise-foundation.test.ts
  6. 13 0
      packages/core/src/__tests__/state-manager.test.ts
  7. 18 1
      packages/core/src/agent/agent-tools.ts
  8. 14 2
      packages/core/src/agent/context-transform.ts
  9. 665 220
      packages/core/src/agents/architect.ts
  10. 5 4
      packages/core/src/agents/chapter-analyzer.ts
  11. 2 2
      packages/core/src/agents/consolidator.ts
  12. 4 3
      packages/core/src/agents/continuity.ts
  13. 4 3
      packages/core/src/agents/planner.ts
  14. 5 4
      packages/core/src/agents/reviser.ts
  15. 8 7
      packages/core/src/agents/writer.ts
  16. 19 2
      packages/core/src/llm/provider.ts
  17. 7 0
      packages/core/src/models/project.ts
  18. 170 5
      packages/core/src/pipeline/runner.ts
  19. 6 0
      packages/core/src/state/manager.ts
  20. 2 1
      packages/core/src/utils/memory-retrieval.ts
  21. 275 0
      packages/core/src/utils/outline-paths.ts
  22. 0 199
      packages/studio/src/components/chat/BookFormCard.tsx
  23. 1 37
      packages/studio/src/components/chat/ChatMessage.tsx
  24. 25 7
      packages/studio/src/components/sidebar/SummarySection.tsx
  25. 7 3
      packages/studio/src/hooks/use-hash-route.ts
  26. 2 110
      packages/studio/src/pages/ChatPage.tsx
  27. 0 3
      packages/studio/src/store/chat/selectors.ts
  28. 2 81
      packages/studio/src/store/chat/slices/create/action.ts
  29. 0 2
      packages/studio/src/store/chat/slices/create/initialState.ts
  30. 0 7
      packages/studio/src/store/chat/slices/message/action.ts
  31. 0 1
      packages/studio/src/store/chat/slices/message/runtime.ts
  32. 0 7
      packages/studio/src/store/chat/types.ts

+ 148 - 0
packages/core/src/__tests__/architect.test.ts

@@ -1,6 +1,7 @@
 import { afterEach, describe, expect, it, vi } from "vitest";
 import { ArchitectAgent } from "../agents/architect.js";
 import type { BookConfig } from "../models/book.js";
+import type { LLMClient } from "../llm/provider.js";
 
 const ZERO_USAGE = {
   promptTokens: 0,
@@ -705,4 +706,151 @@ describe("ArchitectAgent", () => {
       expect.objectContaining({ temperature: 0.7, maxTokens: 16384 }),
     );
   });
+
+  // ---- Phase 5 段落式架构稿专项 ----
+
+  // 测试 stub:chat 会被 vi.spyOn 拦截,client.defaults 运行时不会被读取。
+  // 故意不填 temperature / maxTokens 等数字——避免在测试里留下"推荐配置"的
+  // 错误示范(maxTokens 填错会误导后续抄到生产,触发 CLAUDE.md 禁止的
+  // maxTokens 回归)。只保留类型要求的身份字段。
+  const buildPhase5Agent = (): ArchitectAgent =>
+    new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+      } as unknown as LLMClient,
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+  const phase5Book = (): BookConfig => ({
+    id: "phase5-book",
+    title: "测试书",
+    platform: "qidian",
+    genre: "xuanhuan",
+    status: "active",
+    targetChapters: 50,
+    chapterWordCount: 3000,
+    language: "zh",
+    createdAt: "2026-04-19T00:00:00.000Z",
+    updatedAt: "2026-04-19T00:00:00.000Z",
+  });
+
+  it("generateFoundation parses story_frame / volume_map / roles sections", async () => {
+    const agent = buildPhase5Agent();
+    const book = phase5Book();
+
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_frame ===",
+          "## 主题与基调",
+          "段落 1 主题段落。",
+          "",
+          "## 核心冲突",
+          "段落 2 冲突段落。",
+          "",
+          "=== SECTION: volume_map ===",
+          "## 段 1",
+          "卷一段落。",
+          "",
+          "=== SECTION: roles ===",
+          "---ROLE---",
+          "tier: major",
+          "name: 林辞",
+          "---CONTENT---",
+          "## 核心标签",
+          "冷静、执着",
+          "",
+          "---ROLE---",
+          "tier: minor",
+          "name: 配角A",
+          "---CONTENT---",
+          "次要角色描写",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "protagonist:",
+          "  name: 林辞",
+          "---",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
+          "|---|---|---|---|---|---|---|---|",
+          "| H001 | 1 | 主线 | open | 0 | 3 | 近期 | 初始线索 |",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    const output = await agent.generateFoundation(book);
+
+    expect(output.storyFrame).toContain("主题与基调");
+    expect(output.volumeMap).toContain("段 1");
+    expect(output.roles).toBeDefined();
+    expect(output.roles!.length).toBe(2);
+    expect(output.roles![0]).toMatchObject({ tier: "major", name: "林辞" });
+    expect(output.roles![1]).toMatchObject({ tier: "minor", name: "配角A" });
+  });
+
+  it("writeFoundationFiles writes outline/ and roles/ when Phase 5 fields present", async () => {
+    const { mkdtemp, rm, access } = await import("node:fs/promises");
+    const { tmpdir } = await import("node:os");
+    const { join } = await import("node:path");
+
+    const agent = buildPhase5Agent();
+    const tmpDir = await mkdtemp(join(tmpdir(), "inkos-arch-test-"));
+    try {
+      await agent.writeFoundationFiles(tmpDir, {
+        storyBible: "legacy shim body",
+        volumeOutline: "legacy outline",
+        bookRules: "---\nversion: \"1.0\"\n---\n",
+        currentState: "",
+        pendingHooks: "| hook_id |",
+        storyFrame: "## 主题\n\n段落内容",
+        volumeMap: "## 卷一\n\n卷一段落",
+        roles: [
+          { tier: "major", name: "林辞", content: "主角描写" },
+          { tier: "minor", name: "配角A", content: "配角描写" },
+        ],
+      }, false, "zh");
+
+      await expect(access(join(tmpDir, "story", "outline", "story_frame.md"))).resolves.not.toThrow();
+      await expect(access(join(tmpDir, "story", "outline", "volume_map.md"))).resolves.not.toThrow();
+      await expect(access(join(tmpDir, "story", "roles", "主要角色", "林辞.md"))).resolves.not.toThrow();
+      await expect(access(join(tmpDir, "story", "roles", "次要角色", "配角A.md"))).resolves.not.toThrow();
+      // Shim 文件也要在(向后兼容读取点用)
+      await expect(access(join(tmpDir, "story", "story_bible.md"))).resolves.not.toThrow();
+      await expect(access(join(tmpDir, "story", "character_matrix.md"))).resolves.not.toThrow();
+      await expect(access(join(tmpDir, "story", "book_rules.md"))).resolves.not.toThrow();
+    } finally {
+      await rm(tmpDir, { recursive: true, force: true });
+    }
+  });
+
+  it("writeFoundationFiles falls back to legacy layout when storyFrame is empty", async () => {
+    const { mkdtemp, rm, access, readFile } = await import("node:fs/promises");
+    const { tmpdir } = await import("node:os");
+    const { join } = await import("node:path");
+
+    const agent = buildPhase5Agent();
+    const tmpDir = await mkdtemp(join(tmpdir(), "inkos-arch-legacy-test-"));
+    try {
+      await agent.writeFoundationFiles(tmpDir, {
+        storyBible: "# Legacy Story Bible\n",
+        volumeOutline: "# Legacy Volume Outline\n",
+        bookRules: "# Legacy Book Rules\n",
+        currentState: "# Current State\n",
+        pendingHooks: "| hook_id |\n",
+      }, false, "zh");
+
+      const storyBible = await readFile(join(tmpDir, "story", "story_bible.md"), "utf-8");
+      expect(storyBible).toContain("Legacy Story Bible");
+      // outline/ 目录是创建的但里面没 story_frame.md
+      await expect(access(join(tmpDir, "story", "outline", "story_frame.md"))).rejects.toThrow();
+    } finally {
+      await rm(tmpDir, { recursive: true, force: true });
+    }
+  });
 });

+ 26 - 0
packages/core/src/__tests__/context-transform.test.ts

@@ -79,4 +79,30 @@ describe("createBookContextTransform", () => {
     const result = await transform(original);
     expect(result).toBe(original);
   });
+
+  it("injects upgrade hint when book is legacy layout (no outline/story_frame.md)", async () => {
+    const transform = createBookContextTransform(bookId, projectRoot);
+    const result = await transform([
+      { role: "user" as const, content: "写下一章", timestamp: Date.now() },
+    ]);
+
+    const injected = result[0] as { role: string; content: string };
+    expect(injected.content).toContain("旧的条目式格式");
+    expect(injected.content).toContain("sub_agent(architect, { revise: true");
+  });
+
+  it("does NOT inject upgrade hint when book is Phase 5 layout", async () => {
+    const outlineDir = join(projectRoot, "books", bookId, "story", "outline");
+    await mkdir(outlineDir, { recursive: true });
+    await writeFile(join(outlineDir, "story_frame.md"), "## 主题\n段落式内容");
+
+    const transform = createBookContextTransform(bookId, projectRoot);
+    const result = await transform([
+      { role: "user" as const, content: "写下一章", timestamp: Date.now() },
+    ]);
+
+    const injected = result[0] as { role: string; content: string };
+    expect(injected.content).not.toContain("旧的条目式格式");
+    expect(injected.content).not.toContain("revise: true");
+  });
 });

+ 151 - 0
packages/core/src/__tests__/outline-paths.test.ts

@@ -0,0 +1,151 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+  readStoryFrame,
+  readVolumeMap,
+  readRoleCards,
+  readCharacterContext,
+  readCurrentStateWithFallback,
+  isNewLayoutBook,
+  isCurrentStateSeedPlaceholder,
+} from "../utils/outline-paths.js";
+
+describe("outline-paths", () => {
+  let bookDir: string;
+
+  beforeEach(async () => {
+    bookDir = await mkdtemp(join(tmpdir(), "inkos-test-book-"));
+    await mkdir(join(bookDir, "story"), { recursive: true });
+  });
+
+  afterEach(async () => {
+    await rm(bookDir, { recursive: true, force: true });
+  });
+
+  // --- isNewLayoutBook ---
+
+  it("isNewLayoutBook returns false when outline/story_frame.md is missing", async () => {
+    expect(await isNewLayoutBook(bookDir)).toBe(false);
+  });
+
+  it("isNewLayoutBook returns true when outline/story_frame.md exists", async () => {
+    await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+    await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "# frame", "utf-8");
+    expect(await isNewLayoutBook(bookDir)).toBe(true);
+  });
+
+  // --- readStoryFrame ---
+
+  it("readStoryFrame prefers new path when present", async () => {
+    await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+    await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "段落式内容", "utf-8");
+    await writeFile(join(bookDir, "story", "story_bible.md"), "legacy content", "utf-8");
+    expect(await readStoryFrame(bookDir)).toBe("段落式内容");
+  });
+
+  it("readStoryFrame falls back to legacy story_bible.md when new path missing", async () => {
+    await writeFile(join(bookDir, "story", "story_bible.md"), "legacy 条目式", "utf-8");
+    expect(await readStoryFrame(bookDir)).toBe("legacy 条目式");
+  });
+
+  it("readStoryFrame returns fallback placeholder when both paths missing", async () => {
+    expect(await readStoryFrame(bookDir, "(未创建)")).toBe("(未创建)");
+  });
+
+  // --- readVolumeMap ---
+
+  it("readVolumeMap falls back to legacy volume_outline.md", async () => {
+    await writeFile(join(bookDir, "story", "volume_outline.md"), "legacy 卷大纲", "utf-8");
+    expect(await readVolumeMap(bookDir)).toBe("legacy 卷大纲");
+  });
+
+  it("readVolumeMap prefers new path", async () => {
+    await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+    await writeFile(join(bookDir, "story", "outline", "volume_map.md"), "段落式卷大纲", "utf-8");
+    await writeFile(join(bookDir, "story", "volume_outline.md"), "legacy", "utf-8");
+    expect(await readVolumeMap(bookDir)).toBe("段落式卷大纲");
+  });
+
+  // --- readRoleCards ---
+
+  it("readRoleCards returns empty when roles/ dir missing", async () => {
+    expect(await readRoleCards(bookDir)).toEqual([]);
+  });
+
+  it("readRoleCards reads major/minor role files", async () => {
+    const majorDir = join(bookDir, "story", "roles", "主要角色");
+    const minorDir = join(bookDir, "story", "roles", "次要角色");
+    await mkdir(majorDir, { recursive: true });
+    await mkdir(minorDir, { recursive: true });
+    await writeFile(join(majorDir, "林辞.md"), "主角内容", "utf-8");
+    await writeFile(join(minorDir, "配角A.md"), "配角内容", "utf-8");
+    const cards = await readRoleCards(bookDir);
+    expect(cards.length).toBe(2);
+    const major = cards.find((c) => c.name === "林辞");
+    const minor = cards.find((c) => c.name === "配角A");
+    expect(major).toMatchObject({ tier: "major", name: "林辞", content: "主角内容" });
+    expect(minor).toMatchObject({ tier: "minor", name: "配角A", content: "配角内容" });
+  });
+
+  // --- readCharacterContext ---
+
+  it("readCharacterContext renders role cards when present", async () => {
+    const majorDir = join(bookDir, "story", "roles", "主要角色");
+    await mkdir(majorDir, { recursive: true });
+    await writeFile(join(majorDir, "林辞.md"), "林辞的描写", "utf-8");
+    const result = await readCharacterContext(bookDir);
+    expect(result).toContain("林辞");
+    expect(result).toContain("主要角色");
+  });
+
+  it("readCharacterContext falls back to legacy character_matrix.md", async () => {
+    await writeFile(join(bookDir, "story", "character_matrix.md"), "legacy 角色矩阵", "utf-8");
+    const result = await readCharacterContext(bookDir);
+    expect(result).toBe("legacy 角色矩阵");
+  });
+
+  // --- readCurrentStateWithFallback ---
+
+  it("readCurrentStateWithFallback returns real content when not a seed", async () => {
+    await writeFile(
+      join(bookDir, "story", "current_state.md"),
+      "# 当前状态\n\n林辞已经到达京城,正在调查失踪案。",
+      "utf-8",
+    );
+    const result = await readCurrentStateWithFallback(bookDir);
+    expect(result).toContain("林辞已经到达京城");
+  });
+
+  it("readCurrentStateWithFallback derives initial state from roles when seed placeholder", async () => {
+    await writeFile(
+      join(bookDir, "story", "current_state.md"),
+      "建书时占位,运行时由 consolidator 每章追加。",
+      "utf-8",
+    );
+    const majorDir = join(bookDir, "story", "roles", "主要角色");
+    await mkdir(majorDir, { recursive: true });
+    await writeFile(
+      join(majorDir, "林辞.md"),
+      "## 当前现状\n\n刚从边关回到京城,尚未与旧友重逢。",
+      "utf-8",
+    );
+    const result = await readCurrentStateWithFallback(bookDir);
+    expect(result).toContain("林辞");
+    expect(result).toContain("初始状态");
+  });
+
+  // --- isCurrentStateSeedPlaceholder ---
+
+  it("isCurrentStateSeedPlaceholder returns true for empty or seed text", () => {
+    expect(isCurrentStateSeedPlaceholder("")).toBe(true);
+    expect(isCurrentStateSeedPlaceholder("  ")).toBe(true);
+    expect(isCurrentStateSeedPlaceholder("建书时占位,运行时追加")).toBe(true);
+    expect(isCurrentStateSeedPlaceholder("Seeded at book creation")).toBe(true);
+  });
+
+  it("isCurrentStateSeedPlaceholder returns false for real content", () => {
+    expect(isCurrentStateSeedPlaceholder("# 状态\n\n林辞已经出发,目标是西域。这是一段很长的内容。".repeat(10))).toBe(false);
+  });
+});

+ 106 - 0
packages/core/src/__tests__/provider.test.ts

@@ -484,3 +484,109 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
     warn.mockRestore();
   });
 });
+
+// ── 回归测试:per-call maxTokens 不能被 config.maxTokens 误封顶 ─────────────
+//
+// 背景 / bug 成因:LLMConfigSchema.maxTokens 有 zod default 8192,曾经
+// createLLMClient 里 `maxTokensCap: config.maxTokens ?? null` 的实现会让 cap
+// 永远等于 config.maxTokens。architect 的 per-call 16384 会被 Math.min(16384,
+// 8192) 裁到 8192,基础设定输出被截断——这是 CLAUDE.md 禁止的 maxTokens 回归。
+//
+// 修复后 maxTokens 和 maxTokensCap 是两个独立字段:
+//   - maxTokens: agent 没传 per-call 时的 fallback
+//   - maxTokensCap: per-call 的硬上限,默认 null(不封顶)
+//
+// 如果后续有人把两个字段的语义合回去、或者把 cap 默认改成非 null,这组回归
+// 测试会立刻挂掉。
+
+describe("createLLMClient maxTokensCap regression", () => {
+  it("setting config.maxTokens alone leaves defaults.maxTokensCap null (no cap)", async () => {
+    const { createLLMClient } = await import("../llm/provider.js");
+    const { LLMConfigSchema } = await import("../models/project.js");
+
+    const client = createLLMClient(LLMConfigSchema.parse({
+      provider: "openai",
+      baseUrl: "http://localhost:0",
+      model: "test-model",
+      maxTokens: 8192, // 用户配了 fallback,但没有显式要求封顶
+    }));
+
+    expect(client.defaults.maxTokens).toBe(8192);
+    expect(client.defaults.maxTokensCap).toBeNull();
+  });
+
+  it("setting config.maxTokensCap flips cap on", async () => {
+    const { createLLMClient } = await import("../llm/provider.js");
+    const { LLMConfigSchema } = await import("../models/project.js");
+
+    const client = createLLMClient(LLMConfigSchema.parse({
+      provider: "openai",
+      baseUrl: "http://localhost:0",
+      model: "test-model",
+      maxTokens: 8192,
+      maxTokensCap: 4096, // 显式要求封顶
+    }));
+
+    expect(client.defaults.maxTokens).toBe(8192);
+    expect(client.defaults.maxTokensCap).toBe(4096);
+  });
+
+  it("defaults (no config keys) leave cap null", async () => {
+    const { createLLMClient } = await import("../llm/provider.js");
+    const { LLMConfigSchema } = await import("../models/project.js");
+
+    const client = createLLMClient(LLMConfigSchema.parse({
+      provider: "openai",
+      baseUrl: "http://localhost:0",
+      model: "test-model",
+    }));
+
+    expect(client.defaults.maxTokensCap).toBeNull();
+  });
+
+  it("per-call maxTokens 16384 reaches the API when config.maxTokens is 8192", async () => {
+    const { createLLMClient } = await import("../llm/provider.js");
+    const { LLMConfigSchema } = await import("../models/project.js");
+
+    const client = createLLMClient(LLMConfigSchema.parse({
+      provider: "openai",
+      baseUrl: "http://localhost:0",
+      model: "test-model",
+      maxTokens: 8192,
+    }));
+
+    mockStreamSimple.mockReset();
+    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
+
+    await chatCompletion(client, "test-model", [
+      { role: "user", content: "architect" },
+    ], { maxTokens: 16384 });
+
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    // 16384 必须原样传到下游,不能被 config.maxTokens=8192 裁成 8192
+    expect(opts.maxTokens).toBe(16384);
+  });
+
+  it("per-call maxTokens is capped when config.maxTokensCap is set explicitly", async () => {
+    const { createLLMClient } = await import("../llm/provider.js");
+    const { LLMConfigSchema } = await import("../models/project.js");
+
+    const client = createLLMClient(LLMConfigSchema.parse({
+      provider: "openai",
+      baseUrl: "http://localhost:0",
+      model: "test-model",
+      maxTokens: 8192,
+      maxTokensCap: 4096, // 用户确实要硬上限
+    }));
+
+    mockStreamSimple.mockReset();
+    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
+
+    await chatCompletion(client, "test-model", [
+      { role: "user", content: "test" },
+    ], { maxTokens: 16384 });
+
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.maxTokens).toBe(4096);
+  });
+});

+ 514 - 0
packages/core/src/__tests__/revise-foundation.test.ts

@@ -0,0 +1,514 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { ArchitectAgent } from "../agents/architect.js";
+import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js";
+import type { ArchitectOutput } from "../agents/architect.js";
+import type { BookConfig } from "../models/book.js";
+import type { LLMClient } from "../llm/provider.js";
+
+// 测试 stub:chat 会被 vi.spyOn 拦截,client.defaults 运行时根本不会被读取。
+// 故意不填 temperature / maxTokens 等数字,避免在测试里留下"推荐配置"的错误
+// 示范(尤其 maxTokens —— 填错会误导后续抄到生产,触发 CLAUDE.md 禁止的
+// maxTokens 回归)。只保留类型要求的身份字段。
+const TEST_CLIENT: LLMClient = {
+  provider: "openai",
+  apiFormat: "chat",
+  stream: false,
+} as unknown as LLMClient;
+
+const buildArchitect = (): ArchitectAgent =>
+  new ArchitectAgent({
+    client: TEST_CLIENT,
+    model: "test-model",
+    projectRoot: process.cwd(),
+  });
+
+const testBook = (): BookConfig => ({
+  id: "test-book", title: "测试书", platform: "qidian", genre: "xuanhuan",
+  status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh",
+  createdAt: "2026-04-19T00:00:00.000Z", updatedAt: "2026-04-19T00:00:00.000Z",
+});
+
+describe("architect generateFoundation with reviseFrom option", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("injects legacy content into the system prompt when reviseFrom is supplied", async () => {
+    const agent = buildArchitect();
+    const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_frame ===",
+          "## 主题",
+          "新段落主题",
+          "",
+          "=== SECTION: volume_map ===",
+          "## 段 1",
+          "新卷一",
+          "",
+          "=== SECTION: roles ===",
+          "---ROLE---",
+          "tier: major",
+          "name: 林辞",
+          "---CONTENT---",
+          "主角",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id |",
+        ].join("\n"),
+        usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
+      });
+
+    await agent.generateFoundation(testBook(), undefined, undefined, {
+      reviseFrom: {
+        storyBible: "- 旧世界观:架空唐代\n- 旧主角:林辞",
+        volumeOutline: "## 第一卷\n- 1. 主角登场",
+        bookRules: "## 规则\n- 禁现代词",
+        characterMatrix: "林辞 - 主角",
+        userFeedback: "升级到段落式架构稿",
+      },
+    });
+
+    const systemMsg = (chatSpy.mock.calls[0]?.[0] as Array<{ role: string; content: string }>)[0]!;
+    expect(systemMsg.content).toContain("把一本已有书的架构稿从条目式升级");
+    expect(systemMsg.content).toContain("旧世界观:架空唐代");
+    expect(systemMsg.content).toContain("升级到段落式架构稿");
+  });
+
+  it("does not inject revisePrompt when reviseFrom is absent", async () => {
+    const agent = buildArchitect();
+    const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_frame ===", "## 主题", "段落",
+          "=== SECTION: volume_map ===", "## 段 1", "卷一",
+          "=== SECTION: roles ===", "---ROLE---", "tier: major", "name: X", "---CONTENT---", "主角",
+          "=== SECTION: book_rules ===", "---", "version: \"1.0\"", "---",
+          "=== SECTION: pending_hooks ===", "| hook_id |",
+        ].join("\n"),
+        usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
+      });
+
+    await agent.generateFoundation(testBook());
+
+    const systemMsg = (chatSpy.mock.calls[0]?.[0] as Array<{ role: string; content: string }>)[0]!;
+    expect(systemMsg.content).not.toContain("把一本已有书的架构稿从条目式升级");
+  });
+});
+
+describe("pipeline.reviseFoundation", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("backs up legacy files and writes Phase 5 output", async () => {
+    const { mkdtemp, writeFile, mkdir, rm, access, readdir } = await import("node:fs/promises");
+    const { tmpdir } = await import("node:os");
+    const { join } = await import("node:path");
+    const { PipelineRunner } = await import("../pipeline/runner.js");
+    const { StateManager } = await import("../state/manager.js");
+
+    const root = await mkdtemp(join(tmpdir(), "inkos-revise-e2e-"));
+    const bookDir = join(root, "books", "legacy-book");
+
+    try {
+      // Construct a 旧书 on disk with 4 legacy files
+      await mkdir(join(bookDir, "story"), { recursive: true });
+      await writeFile(join(bookDir, "story", "story_bible.md"), "# 旧书架构稿\n\n- 架空唐代\n- 主角林辞", "utf-8");
+      await writeFile(join(bookDir, "story", "volume_outline.md"), "## 第一卷\n- 主角登场", "utf-8");
+      await writeFile(join(bookDir, "story", "book_rules.md"), "## 规则\n- 禁现代词", "utf-8");
+      await writeFile(join(bookDir, "story", "character_matrix.md"), "## 角色\n林辞 - 主角", "utf-8");
+      await writeFile(join(bookDir, "book.json"), JSON.stringify({
+        id: "legacy-book", title: "旧书", platform: "qidian", genre: "xuanhuan",
+        status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh",
+        createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z",
+      }), "utf-8");
+
+      // Stub architect.generateFoundation → Phase 5 output
+      const mockFoundation: ArchitectOutput = {
+        storyBible: "(shim)",
+        volumeOutline: "(shim)",
+        bookRules: "---\nversion: \"1.0\"\n---\n",
+        currentState: "",
+        pendingHooks: "| hook_id |",
+        storyFrame: "## 主题\n\n段落式主题",
+        volumeMap: "## 段 1\n\n卷一段落",
+        roles: [{ tier: "major", name: "林辞", content: "主角段落描写" }],
+      };
+      vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue(mockFoundation);
+      vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({
+        passed: true, totalScore: 90, dimensions: [], overallFeedback: "ok",
+      } as unknown as Awaited<ReturnType<FoundationReviewerAgent["review"]>>);
+
+      // Minimal config for PipelineRunner — 共用 TEST_CLIENT 避免重复。
+      const state = new StateManager(root);
+      const runner = new PipelineRunner({
+        state,
+        projectRoot: root,
+        client: TEST_CLIENT,
+        model: "test-model",
+      } as unknown as ConstructorParameters<typeof PipelineRunner>[0]);
+
+      await runner.reviseFoundation("legacy-book", "升级到段落式");
+
+      // New files created
+      await expect(access(join(bookDir, "story", "outline", "story_frame.md"))).resolves.not.toThrow();
+      await expect(access(join(bookDir, "story", "outline", "volume_map.md"))).resolves.not.toThrow();
+      await expect(access(join(bookDir, "story", "roles", "主要角色", "林辞.md"))).resolves.not.toThrow();
+      // Backup exists
+      const storyEntries = await readdir(join(bookDir, "story"));
+      const backupDir = storyEntries.find((e) => e.startsWith(".backup-phase4-"));
+      expect(backupDir).toBeDefined();
+      await expect(access(join(bookDir, "story", backupDir!, "story_bible.md"))).resolves.not.toThrow();
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  // ---- Bug fix regression suite ----
+
+  it("revise 不重置运行时状态文件(current_state / pending_hooks / particle_ledger / subplot_board / emotional_arcs 保留章节累积)", async () => {
+    const { mkdtemp, writeFile, mkdir, rm, readFile } = await import("node:fs/promises");
+    const { tmpdir } = await import("node:os");
+    const { join } = await import("node:path");
+    const { PipelineRunner } = await import("../pipeline/runner.js");
+    const { StateManager } = await import("../state/manager.js");
+
+    const root = await mkdtemp(join(tmpdir(), "inkos-revise-runtime-"));
+    const bookDir = join(root, "books", "live-book");
+
+    try {
+      // 构造一本已经写过 N 章的 legacy 旧书——架构稿文件 + 运行时状态文件
+      // 全都有"第 N 章累积后"的真实内容
+      await mkdir(join(bookDir, "story"), { recursive: true });
+      await writeFile(join(bookDir, "story", "story_bible.md"), "# 架构稿\n- 世界观", "utf-8");
+      await writeFile(join(bookDir, "story", "volume_outline.md"), "## 卷一", "utf-8");
+      await writeFile(join(bookDir, "story", "book_rules.md"), "## 规则", "utf-8");
+      await writeFile(join(bookDir, "story", "character_matrix.md"), "## 角色", "utf-8");
+      // 运行时状态(模拟 consolidator 累积了 20 章后的内容)
+      await writeFile(join(bookDir, "story", "current_state.md"), "# 当前状态\n\n第 20 章结束:主角在京城查失踪案。", "utf-8");
+      await writeFile(join(bookDir, "story", "pending_hooks.md"), "| H001 | 1 | 主线 | open | 15 | ... 推进到 15 章 ... |", "utf-8");
+      await writeFile(join(bookDir, "story", "particle_ledger.md"), "# 资源账本\n\n| 20 | 500 | 积累 | - | 10 | 510 | 第 20 章 |", "utf-8");
+      await writeFile(join(bookDir, "story", "subplot_board.md"), "# 支线\n\n| S1 | 宫廷阴谋 | ... | 5 | 18 | 13 | active | 已推到 18 章 |", "utf-8");
+      await writeFile(join(bookDir, "story", "emotional_arcs.md"), "# 情感弧线\n\n| 林辞 | 15 | 愤怒 | 发现背叛 | 8 | 上升 |", "utf-8");
+      await writeFile(join(bookDir, "book.json"), JSON.stringify({
+        id: "live-book", title: "写了 20 章的书", platform: "qidian", genre: "xuanhuan",
+        status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh",
+        createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z",
+      }), "utf-8");
+
+      const mockFoundation: ArchitectOutput = {
+        storyBible: "(shim)", volumeOutline: "(shim)",
+        bookRules: "---\nversion: \"1.0\"\n---\n",
+        currentState: "", pendingHooks: "| hook_id | ...(新生成,不该被写)|",
+        storyFrame: "## 主题\n段落式",
+        volumeMap: "## 段 1\n卷一",
+        roles: [{ tier: "major", name: "林辞", content: "新卡" }],
+      };
+      vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue(mockFoundation);
+      vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({
+        passed: true, totalScore: 90, dimensions: [], overallFeedback: "ok",
+      } as unknown as Awaited<ReturnType<FoundationReviewerAgent["review"]>>);
+
+      const state = new StateManager(root);
+      const runner = new PipelineRunner({
+        state, projectRoot: root, client: TEST_CLIENT, model: "test-model",
+      } as unknown as ConstructorParameters<typeof PipelineRunner>[0]);
+
+      await runner.reviseFoundation("live-book", "改下主角设定");
+
+      // 5 个运行时状态文件**必须保持原内容**(消费者看到的是"写了 20 章后"的状态,
+      // 不是重置为空模板或架构师新输出)
+      const currentState = await readFile(join(bookDir, "story", "current_state.md"), "utf-8");
+      expect(currentState).toContain("第 20 章结束:主角在京城查失踪案");
+      expect(currentState).not.toContain("建书时占位");  // init 模式的 seed 占位不该被写
+
+      const pendingHooks = await readFile(join(bookDir, "story", "pending_hooks.md"), "utf-8");
+      expect(pendingHooks).toContain("推进到 15 章");
+      expect(pendingHooks).not.toContain("(新生成,不该被写)");
+
+      const ledger = await readFile(join(bookDir, "story", "particle_ledger.md"), "utf-8");
+      expect(ledger).toContain("第 20 章");
+      expect(ledger).not.toContain("| 0 | 0 | 初始化 |");  // init 模式的初始模板
+
+      const subplot = await readFile(join(bookDir, "story", "subplot_board.md"), "utf-8");
+      expect(subplot).toContain("已推到 18 章");
+
+      const emotional = await readFile(join(bookDir, "story", "emotional_arcs.md"), "utf-8");
+      expect(emotional).toContain("发现背叛");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("Phase 5 书二次 revise 时从 outline/roles 权威源读(不把 shim 喂给 architect)", async () => {
+    const { mkdtemp, writeFile, mkdir, rm } = await import("node:fs/promises");
+    const { tmpdir } = await import("node:os");
+    const { join } = await import("node:path");
+    const { PipelineRunner } = await import("../pipeline/runner.js");
+    const { StateManager } = await import("../state/manager.js");
+
+    const root = await mkdtemp(join(tmpdir(), "inkos-revise-phase5-"));
+    const bookDir = join(root, "books", "phase5-book");
+
+    try {
+      // 构造一本已经是 Phase 5 的书
+      await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+      await mkdir(join(bookDir, "story", "roles", "主要角色"), { recursive: true });
+      await mkdir(join(bookDir, "story", "roles", "次要角色"), { recursive: true });
+      // outline/ 是权威,内容完整
+      await writeFile(join(bookDir, "story", "outline", "story_frame.md"),
+        "## 主题与基调\n完整的段落式世界观描写,包含所有设定细节、人物关系、剧情主线。" + "a".repeat(5000),
+        "utf-8");
+      await writeFile(join(bookDir, "story", "outline", "volume_map.md"),
+        "## 段 1\n完整卷大纲描写。" + "b".repeat(5000),
+        "utf-8");
+      // roles/ 是权威,内容完整
+      await writeFile(join(bookDir, "story", "roles", "主要角色", "林辞.md"),
+        "## 核心标签\n冷静、执着\n\n## 主角线\n完整角色线,3000 字描写,反映复杂内在位移" + "c".repeat(3000),
+        "utf-8");
+      // story_bible.md / character_matrix.md 是 shim(只有指针和摘录)
+      await writeFile(join(bookDir, "story", "story_bible.md"),
+        "# 故事圣经(已废弃)\n\n> 权威来源是 outline/story_frame.md\n\n## story_frame 摘录\n\n只有前 2000 字...",
+        "utf-8");
+      await writeFile(join(bookDir, "story", "character_matrix.md"),
+        "# 角色矩阵(已废弃)\n\n> 权威来源是 roles/ 目录\n\n## 主要角色\n\n- roles/主要角色/林辞.md",
+        "utf-8");
+      await writeFile(join(bookDir, "story", "book_rules.md"), "# 规则 shim", "utf-8");
+      await writeFile(join(bookDir, "story", "volume_outline.md"), "## 卷一 shim", "utf-8");
+      await writeFile(join(bookDir, "book.json"), JSON.stringify({
+        id: "phase5-book", title: "Phase 5 书", platform: "qidian", genre: "xuanhuan",
+        status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh",
+        createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-10T00:00:00.000Z",
+      }), "utf-8");
+
+      const generateSpy = vi.spyOn(ArchitectAgent.prototype, "generateFoundation")
+        .mockResolvedValue({
+          storyBible: "(shim)", volumeOutline: "(shim)",
+          bookRules: "---\nversion: \"1.0\"\n---\n",
+          currentState: "", pendingHooks: "| hook_id |",
+          storyFrame: "## 主题\n段落式 v2",
+          volumeMap: "## 段 1\n卷一 v2",
+          roles: [{ tier: "major", name: "林辞", content: "新卡 v2" }],
+        });
+      vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({
+        passed: true, totalScore: 90, dimensions: [], overallFeedback: "ok",
+      } as unknown as Awaited<ReturnType<FoundationReviewerAgent["review"]>>);
+
+      const state = new StateManager(root);
+      const runner = new PipelineRunner({
+        state, projectRoot: root, client: TEST_CLIENT, model: "test-model",
+      } as unknown as ConstructorParameters<typeof PipelineRunner>[0]);
+
+      await runner.reviseFoundation("phase5-book", "调整某个角色设定");
+
+      // 检查传给 architect 的 reviseFrom.storyBible 和 characterMatrix 是权威全文,
+      // 不是 shim 摘录
+      const call = generateSpy.mock.calls[0];
+      const options = call?.[3] as { reviseFrom?: { storyBible: string; characterMatrix: string } };
+      expect(options?.reviseFrom?.storyBible).toContain("完整的段落式世界观描写");
+      expect(options?.reviseFrom?.storyBible).not.toContain("已废弃");
+      expect(options?.reviseFrom?.characterMatrix).toContain("完整角色线,3000 字描写");
+      expect(options?.reviseFrom?.characterMatrix).not.toContain("roles/主要角色/林辞.md");  // shim 里才有文件路径列表
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("revise 清空旧 role 文件(删除/改名角色后旧卡片不残留)", async () => {
+    const { mkdtemp, writeFile, mkdir, rm, access } = await import("node:fs/promises");
+    const { tmpdir } = await import("node:os");
+    const { join } = await import("node:path");
+    const { PipelineRunner } = await import("../pipeline/runner.js");
+    const { StateManager } = await import("../state/manager.js");
+
+    const root = await mkdtemp(join(tmpdir(), "inkos-revise-ghost-"));
+    const bookDir = join(root, "books", "ghost-book");
+
+    try {
+      // 初始状态:Phase 5 书已有 3 个主要角色、2 个次要角色
+      await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+      await mkdir(join(bookDir, "story", "roles", "主要角色"), { recursive: true });
+      await mkdir(join(bookDir, "story", "roles", "次要角色"), { recursive: true });
+      await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "## 主题", "utf-8");
+      await writeFile(join(bookDir, "story", "outline", "volume_map.md"), "## 段 1", "utf-8");
+      await writeFile(join(bookDir, "story", "roles", "主要角色", "林辞.md"), "老卡", "utf-8");
+      await writeFile(join(bookDir, "story", "roles", "主要角色", "要删掉的人.md"), "应该消失", "utf-8");
+      await writeFile(join(bookDir, "story", "roles", "主要角色", "要改名的A.md"), "改名前", "utf-8");
+      await writeFile(join(bookDir, "story", "roles", "次要角色", "老配角.md"), "老次要", "utf-8");
+      await writeFile(join(bookDir, "story", "roles", "次要角色", "要删掉的次要.md"), "应该消失", "utf-8");
+      await writeFile(join(bookDir, "story", "book_rules.md"), "", "utf-8");
+      await writeFile(join(bookDir, "story", "character_matrix.md"), "", "utf-8");
+      await writeFile(join(bookDir, "story", "story_bible.md"), "", "utf-8");
+      await writeFile(join(bookDir, "story", "volume_outline.md"), "", "utf-8");
+      await writeFile(join(bookDir, "book.json"), JSON.stringify({
+        id: "ghost-book", title: "测试", platform: "qidian", genre: "xuanhuan",
+        status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh",
+        createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-10T00:00:00.000Z",
+      }), "utf-8");
+
+      // architect revise 只输出 2 个新 role(主角保留、一个改名后的新 id、删了 "要删掉的人")
+      vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
+        storyBible: "(shim)", volumeOutline: "(shim)",
+        bookRules: "---\nversion: \"1.0\"\n---\n",
+        currentState: "", pendingHooks: "| hook_id |",
+        storyFrame: "## 主题\n新", volumeMap: "## 段 1\n新",
+        roles: [
+          { tier: "major", name: "林辞", content: "新卡" },
+          { tier: "major", name: "改名后的B", content: "改名后" },
+        ],
+      });
+      vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({
+        passed: true, totalScore: 90, dimensions: [], overallFeedback: "ok",
+      } as unknown as Awaited<ReturnType<FoundationReviewerAgent["review"]>>);
+
+      const state = new StateManager(root);
+      const runner = new PipelineRunner({
+        state, projectRoot: root, client: TEST_CLIENT, model: "test-model",
+      } as unknown as ConstructorParameters<typeof PipelineRunner>[0]);
+
+      await runner.reviseFoundation("ghost-book", "精简角色");
+
+      // 新输出的 2 个 role 应该存在
+      await expect(access(join(bookDir, "story", "roles", "主要角色", "林辞.md"))).resolves.not.toThrow();
+      await expect(access(join(bookDir, "story", "roles", "主要角色", "改名后的B.md"))).resolves.not.toThrow();
+      // 旧的 5 个 role 文件里没出现在新输出的,**必须被清空**
+      await expect(access(join(bookDir, "story", "roles", "主要角色", "要删掉的人.md"))).rejects.toThrow();
+      await expect(access(join(bookDir, "story", "roles", "主要角色", "要改名的A.md"))).rejects.toThrow();
+      await expect(access(join(bookDir, "story", "roles", "次要角色", "老配角.md"))).rejects.toThrow();
+      await expect(access(join(bookDir, "story", "roles", "次要角色", "要删掉的次要.md"))).rejects.toThrow();
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("revise 模式 + LLM 意外输出 legacy 格式 → 抛错且不动架构稿文件", async () => {
+    const { mkdtemp, writeFile, mkdir, rm, readFile } = await import("node:fs/promises");
+    const { tmpdir } = await import("node:os");
+    const { join } = await import("node:path");
+    const { PipelineRunner } = await import("../pipeline/runner.js");
+    const { StateManager } = await import("../state/manager.js");
+
+    const root = await mkdtemp(join(tmpdir(), "inkos-revise-legacyfallback-"));
+    const bookDir = join(root, "books", "safe-book");
+
+    try {
+      // 构造 Phase 5 书 —— outline/ + roles/ 有原始内容
+      await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+      await mkdir(join(bookDir, "story", "roles", "主要角色"), { recursive: true });
+      await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "原始 story_frame", "utf-8");
+      await writeFile(join(bookDir, "story", "outline", "volume_map.md"), "原始 volume_map", "utf-8");
+      await writeFile(join(bookDir, "story", "roles", "主要角色", "原始角色.md"), "原始角色内容", "utf-8");
+      await writeFile(join(bookDir, "story", "story_bible.md"), "shim 指针", "utf-8");
+      await writeFile(join(bookDir, "story", "character_matrix.md"), "", "utf-8");
+      await writeFile(join(bookDir, "story", "volume_outline.md"), "", "utf-8");
+      await writeFile(join(bookDir, "story", "book_rules.md"), "", "utf-8");
+      await writeFile(join(bookDir, "book.json"), JSON.stringify({
+        id: "safe-book", title: "t", platform: "qidian", genre: "xuanhuan",
+        status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh",
+        createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-10T00:00:00.000Z",
+      }), "utf-8");
+
+      // Stub architect → 模拟 LLM 回退 legacy 输出(storyFrame 为空 / 没 roles)
+      vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
+        storyBible: "LLM 产出的 legacy story bible",
+        volumeOutline: "LLM 产出的 legacy volume outline",
+        bookRules: "---\nversion: \"1.0\"\n---\n",
+        currentState: "",
+        pendingHooks: "| hook_id |",
+        // 故意不填 storyFrame / volumeMap / roles —— 模拟 LLM 回退 legacy
+      } as unknown as Awaited<ReturnType<ArchitectAgent["generateFoundation"]>>);
+      vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({
+        passed: true, totalScore: 90, dimensions: [], overallFeedback: "ok",
+      } as unknown as Awaited<ReturnType<FoundationReviewerAgent["review"]>>);
+
+      const state = new StateManager(root);
+      const runner = new PipelineRunner({
+        state, projectRoot: root, client: TEST_CLIENT, model: "test-model",
+      } as unknown as ConstructorParameters<typeof PipelineRunner>[0]);
+
+      // revise 应该抛错
+      await expect(runner.reviseFoundation("safe-book", "改"))
+        .rejects.toThrow(/legacy-format output.*NOT been modified/);
+
+      // 架构稿原始文件**必须保持不变**(writeFoundationFiles 在抛错前没写任何文件)
+      // 注意:rolesMajorDir 在 reviseFoundation 的 Step 5 里会被 mkdir(但不删),
+      // 写角色卡的 loop 在抛错之前,具体行为看实现:抛错在 rm + mkdir 之后但 writeFile 之前。
+      // 所以旧角色文件可能被 rm 删掉,但那是在 runner.reviseFoundation 已经备份到
+      // .backup-phase5-<ts>/ 之后。验证备份里有原始内容即可。
+      const storyFrame = await readFile(join(bookDir, "story", "outline", "story_frame.md"), "utf-8");
+      expect(storyFrame).toBe("原始 story_frame");  // outline/ 没被动
+
+      const volumeMap = await readFile(join(bookDir, "story", "outline", "volume_map.md"), "utf-8");
+      expect(volumeMap).toBe("原始 volume_map");
+
+      // story_bible.md 也没被覆盖(legacy 输出的 "LLM 产出的 legacy story bible" 没写进去)
+      const storyBible = await readFile(join(bookDir, "story", "story_bible.md"), "utf-8");
+      expect(storyBible).toBe("shim 指针");
+      expect(storyBible).not.toContain("LLM 产出的 legacy");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("Phase 5 revise 备份目录带 phase5 tag 并包含 outline/ + roles/", async () => {
+    const { mkdtemp, writeFile, mkdir, rm, readdir, access } = await import("node:fs/promises");
+    const { tmpdir } = await import("node:os");
+    const { join } = await import("node:path");
+    const { PipelineRunner } = await import("../pipeline/runner.js");
+    const { StateManager } = await import("../state/manager.js");
+
+    const root = await mkdtemp(join(tmpdir(), "inkos-revise-backup-"));
+    const bookDir = join(root, "books", "p5");
+
+    try {
+      await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+      await mkdir(join(bookDir, "story", "roles", "主要角色"), { recursive: true });
+      await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "原 frame", "utf-8");
+      await writeFile(join(bookDir, "story", "outline", "volume_map.md"), "原 map", "utf-8");
+      await writeFile(join(bookDir, "story", "roles", "主要角色", "A.md"), "原角色 A", "utf-8");
+      await writeFile(join(bookDir, "story", "book_rules.md"), "", "utf-8");
+      await writeFile(join(bookDir, "story", "character_matrix.md"), "", "utf-8");
+      await writeFile(join(bookDir, "story", "story_bible.md"), "", "utf-8");
+      await writeFile(join(bookDir, "story", "volume_outline.md"), "", "utf-8");
+      await writeFile(join(bookDir, "book.json"), JSON.stringify({
+        id: "p5", title: "t", platform: "qidian", genre: "xuanhuan",
+        status: "active", targetChapters: 50, chapterWordCount: 3000, language: "zh",
+        createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-10T00:00:00.000Z",
+      }), "utf-8");
+
+      vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
+        storyBible: "(shim)", volumeOutline: "(shim)",
+        bookRules: "---\nversion: \"1.0\"\n---\n",
+        currentState: "", pendingHooks: "| hook_id |",
+        storyFrame: "## 新", volumeMap: "## 新",
+        roles: [{ tier: "major", name: "B", content: "新" }],
+      });
+      vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({
+        passed: true, totalScore: 90, dimensions: [], overallFeedback: "ok",
+      } as unknown as Awaited<ReturnType<FoundationReviewerAgent["review"]>>);
+
+      const state = new StateManager(root);
+      const runner = new PipelineRunner({
+        state, projectRoot: root, client: TEST_CLIENT, model: "test-model",
+      } as unknown as ConstructorParameters<typeof PipelineRunner>[0]);
+
+      await runner.reviseFoundation("p5", "改");
+
+      const entries = await readdir(join(bookDir, "story"));
+      const backupDir = entries.find((e) => e.startsWith(".backup-phase5-"));
+      expect(backupDir).toBeDefined();
+      // backup 应该包含 outline/ 和 roles/(Phase 5 权威源)
+      await expect(access(join(bookDir, "story", backupDir!, "outline", "story_frame.md"))).resolves.not.toThrow();
+      await expect(access(join(bookDir, "story", backupDir!, "outline", "volume_map.md"))).resolves.not.toThrow();
+      await expect(access(join(bookDir, "story", backupDir!, "roles", "主要角色", "A.md"))).resolves.not.toThrow();
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+});

+ 13 - 0
packages/core/src/__tests__/state-manager.test.ts

@@ -771,6 +771,19 @@ describe("StateManager", () => {
       expect(runtimeStat.isDirectory()).toBe(true);
     });
 
+    it("creates Phase 5 outline/ and roles/ directories", async () => {
+      await manager.ensureControlDocuments("phase5-book");
+
+      const storyDir = join(manager.bookDir("phase5-book"), "story");
+      const outlineStat = await stat(join(storyDir, "outline"));
+      const rolesMajorStat = await stat(join(storyDir, "roles", "主要角色"));
+      const rolesMinorStat = await stat(join(storyDir, "roles", "次要角色"));
+
+      expect(outlineStat.isDirectory()).toBe(true);
+      expect(rolesMajorStat.isDirectory()).toBe(true);
+      expect(rolesMinorStat.isDirectory()).toBe(true);
+    });
+
     it("bootstraps and returns safe defaults for legacy books", async () => {
       const storyDir = join(manager.bookDir("legacy-book"), "story");
       await mkdir(storyDir, { recursive: true });

+ 18 - 1
packages/core/src/agent/agent-tools.ts

@@ -75,6 +75,12 @@ const SubAgentParams = Type.Object({
   ], { description: "architect only: writing language. Default: zh" })),
   targetChapters: Type.Optional(Type.Number({ description: "architect only: total chapter count. Default: 200" })),
   chapterWordCount: Type.Optional(Type.Number({ description: "architect/writer: words per chapter. Default: 3000" })),
+  revise: Type.Optional(Type.Boolean({
+    description: "architect only: true 表示在已有书上重新生成架构稿(比如把旧的条目式格式升级成段落式架构稿 + 一人一卡的角色目录、或者按 feedback 调整某些细节),而不是新建书籍。需要同时提供 bookId",
+  })),
+  feedback: Type.Optional(Type.String({
+    description: "architect only: revise 模式下的调整要求。举例:把架构稿从条目式升级成段落式架构稿、某个角色设定需要重新设计、主线冲突表达太弱需要加强等。如果是架构稿评审未通过要求重写的场景,把评审意见的 overallFeedback 原样传入即可",
+  })),
   // -- reviser params --
   mode: Type.Optional(Type.Union([
     Type.Literal("spot-fix"),
@@ -120,7 +126,7 @@ export function createSubAgentTool(
       _signal?: AbortSignal,
       onUpdate?: AgentToolUpdateCallback,
     ): Promise<AgentToolResult<undefined>> {
-      const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, mode, format, approvedOnly } = params;
+      const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, revise, feedback, mode, format, approvedOnly } = params;
 
       const progress = (msg: string) => {
         onUpdate?.(textResult(msg));
@@ -129,6 +135,17 @@ export function createSubAgentTool(
       try {
         switch (agent) {
           case "architect": {
+            if (revise) {
+              if (!bookId) {
+                return textResult("Error: architect revise 模式需要 bookId,用于定位要重写的书。");
+              }
+              progress(`Revising foundation for "${bookId}"...`);
+              await pipeline.reviseFoundation(bookId, feedback ?? instruction);
+              progress(`Foundation revised for "${bookId}".`);
+              return textResult(
+                `Book "${bookId}" 架构稿已按要求重写。原书的条目式架构稿已备份到 story/.backup-phase4-<时间戳>/。`,
+              );
+            }
             if (activeBookId) {
               return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
             }

+ 14 - 2
packages/core/src/agent/context-transform.ts

@@ -2,6 +2,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
 import type { UserMessage } from "@mariozechner/pi-ai";
 import { readdir, readFile } from "node:fs/promises";
 import { join } from "node:path";
+import { isNewLayoutBook } from "../utils/outline-paths.js";
 
 /** Files read in this order; anything else in story/ comes after, sorted alphabetically. */
 const PRIORITY_FILES = [
@@ -11,6 +12,12 @@ const PRIORITY_FILES = [
   "current_focus.md",
 ];
 
+const UPGRADE_HINT =
+  "[提示] 当前这本书的架构稿是旧的条目式格式(story_bible.md / volume_outline.md / character_matrix.md)。" +
+  "如果作者有意愿升级成段落式架构稿 + 一人一卡的角色目录(outline/story_frame.md + outline/volume_map.md + roles/)," +
+  "可以调用 `sub_agent(architect, { revise: true, bookId, feedback: \"把架构稿从条目式升级成段落式架构稿,并把角色矩阵拆成 roles 目录一人一卡\" })`。" +
+  "升级只改架构稿,不动已写的章节。在作者没明确同意前不要主动触发。";
+
 export function createBookContextTransform(
   bookId: string | null,
   projectRoot: string,
@@ -19,14 +26,19 @@ export function createBookContextTransform(
     return async (messages) => messages;
   }
 
-  const storyDir = join(projectRoot, "books", bookId, "story");
+  const bookDir = join(projectRoot, "books", bookId);
+  const storyDir = join(bookDir, "story");
 
   return async (messages) => {
     const sections = await readTruthFiles(storyDir);
     if (sections.length === 0) return messages;
 
+    const isNew = await isNewLayoutBook(bookDir);
+    const hintBlock = isNew ? "" : `\n\n${UPGRADE_HINT}`;
+
     const body =
-      "[以下是当前书籍的真相文件,每次对话时自动从磁盘读取注入。请基于这些内容进行创作和判断。]\n\n" +
+      "[以下是当前书籍的真相文件,每次对话时自动从磁盘读取注入。请基于这些内容进行创作和判断。]" +
+      hintBlock + "\n\n" +
       sections.map((s) => `=== ${s.name} ===\n${s.content}`).join("\n\n");
 
     const injected: UserMessage = {

+ 665 - 220
packages/core/src/agents/architect.ts

@@ -2,16 +2,42 @@ import { BaseAgent } from "./base.js";
 import type { BookConfig, FanficMode } from "../models/book.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import { readGenreProfile } from "./rules-reader.js";
-import { writeFile, mkdir } from "node:fs/promises";
+import { writeFile, mkdir, rm } from "node:fs/promises";
 import { join } from "node:path";
 import { renderHookSnapshot } from "../utils/memory-retrieval.js";
 
+export interface ArchitectRole {
+  readonly tier: "major" | "minor";
+  readonly name: string;
+  readonly content: string;
+}
+
 export interface ArchitectOutput {
   readonly storyBible: string;
   readonly volumeOutline: string;
   readonly bookRules: string;
   readonly currentState: string;
   readonly pendingHooks: string;
+  // Phase 5 新增字段:段落式架构稿 + 一人一卡角色目录。Optional,老测试 fixture 只 mock
+  // 旧字段时依然能编译;架构师在运行时始终填充这些。
+  readonly storyFrame?: string;
+  readonly volumeMap?: string;
+  readonly rhythmPrinciples?: string;
+  readonly roles?: ReadonlyArray<ArchitectRole>;
+}
+
+/** 拆 markdown 的首部 YAML frontmatter 和正文。没有 frontmatter 时返回 frontmatter: null。 */
+function extractYamlFrontmatter(raw: string): { frontmatter: string | null; body: string } {
+  if (!raw) return { frontmatter: null, body: "" };
+  const stripped = raw.replace(/^```(?:md|markdown|yaml)?\s*\n/, "").replace(/\n```\s*$/, "");
+  const leadingMatch = stripped.match(/^\s*---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
+  if (!leadingMatch) {
+    return { frontmatter: null, body: stripped };
+  }
+  return {
+    frontmatter: `---\n${leadingMatch[1]}\n---`,
+    body: leadingMatch[2].trim(),
+  };
 }
 
 export class ArchitectAgent extends BaseAgent {
@@ -23,6 +49,15 @@ export class ArchitectAgent extends BaseAgent {
     book: BookConfig,
     externalContext?: string,
     reviewFeedback?: string,
+    options?: {
+      reviseFrom?: {
+        storyBible: string;
+        volumeOutline: string;
+        bookRules: string;
+        characterMatrix: string;
+        userFeedback: string;
+      };
+    },
   ): Promise<ArchitectOutput> {
     const { profile: gp, body: genreBody } =
       await readGenreProfile(this.ctx.projectRoot, book.genre);
@@ -32,6 +67,9 @@ export class ArchitectAgent extends BaseAgent {
       ? `\n\n## 外部指令\n以下是来自外部系统的创作指令,请将其融入设定中:\n\n${externalContext}\n`
       : "";
     const reviewFeedbackBlock = this.buildReviewFeedbackBlock(reviewFeedback, resolvedLanguage);
+    const revisePrompt = options?.reviseFrom
+      ? this.buildRevisePrompt(options.reviseFrom)
+      : "";
 
     const numericalBlock = gp.numericalSystem
       ? `- 有明确的数值/资源体系可追踪
@@ -46,108 +84,210 @@ export class ArchitectAgent extends BaseAgent {
       ? "- 需要年代考据支撑(在 book_rules 中设置 eraConstraints)"
       : "";
 
-    const storyBiblePrompt = resolvedLanguage === "en"
-      ? `Use structured second-level headings:
-## 01_Worldview
-World setting, historical-social frame, and core rules
+    const basePrompt = resolvedLanguage === "en"
+      ? this.buildEnglishFoundationPrompt(book, gp, genreBody, contextBlock, reviewFeedbackBlock, numericalBlock, powerBlock, eraBlock)
+      : this.buildChineseFoundationPrompt(book, gp, genreBody, contextBlock, reviewFeedbackBlock, numericalBlock, powerBlock, eraBlock);
+    const systemPrompt = revisePrompt + basePrompt;
 
-## 02_Protagonist
-Protagonist setup (identity / advantage / personality core / behavioral boundaries)
+    const langPrefix = resolvedLanguage === "en"
+      ? `【LANGUAGE OVERRIDE】ALL output (story_frame, volume_map, roles, book_rules, pending_hooks) MUST be written in English. Character names, place names, and all prose must be in English. The === SECTION: === tags remain unchanged. Do NOT emit rhythm_principles or current_state sections — rhythm principles live inside the last paragraph of volume_map; environment/era anchors (when relevant) are woven into story_frame's world-tonal-ground paragraph.\n\n`
+      : "";
+    const userMessage = resolvedLanguage === "en"
+      ? `Generate the complete foundation for a ${gp.name} novel titled "${book.title}". Write everything in English.`
+      : `请为标题为"${book.title}"的${gp.name}小说生成完整基础设定。`;
 
-## 03_Factions_and_Characters
-Major factions and important supporting characters (for each: name, identity, motivation, relationship to protagonist, independent goal)
+    const response = await this.chat([
+      { role: "system", content: langPrefix + systemPrompt },
+      { role: "user", content: userMessage },
+    ], { temperature: 0.8, maxTokens: 16384 });
 
-## 04_Geography_and_Environment
-Map / scene design and environmental traits
+    return this.parseSections(response.content);
+  }
 
-## 05_Title_and_Blurb
-Title method:
-- Keep the title clear, direct, and easy to understand
-- Use a format that immediately signals genre and core appeal
-- Avoid overly literary or misleading titles
-
-Blurb method (within 300 words, choose one):
-1. Open with conflict, then reveal the hook, then leave suspense
-2. Summarize only the main line and keep a clear suspense gap
-3. Use a miniature scene that captures the book's strongest pull
-
-Core blurb principle:
-- The blurb is product copy that must make readers want to click`
-      : `用结构化二级标题组织:
-## 01_世界观
-世界观设定、核心规则体系
+  private buildRevisePrompt(reviseFrom: {
+    storyBible: string;
+    volumeOutline: string;
+    bookRules: string;
+    characterMatrix: string;
+    userFeedback: string;
+  }): string {
+    return `你在把一本已有书的架构稿从条目式升级成段落式架构稿 + 一人一卡的角色目录。
 
-## 02_主角
-主角设定(身份/金手指/性格底色/行为边界)
+原书信息(条目式架构稿原文,这是权威内容,必须完整保留里面的世界观 / 角色 / 主线 / 伏笔):
 
-## 03_势力与人物
-势力分布、重要配角(每人:名字、身份、动机、与主角关系、独立目标)
+【story_bible.md 全文】
+${reviseFrom.storyBible || "(无)"}
 
-## 04_地理与环境
-地图/场景设定、环境特色
+【volume_outline.md 全文】
+${reviseFrom.volumeOutline || "(无)"}
 
-## 05_书名与简介
-书名方法论:
-- 书名必须简单扼要、通俗易懂,读者看到书名就能知道题材和主题
-- 采用"题材+核心爽点+主角行为"的长书名格式,避免文艺化
-- 融入平台当下热点词汇,吸引精准流量
-- 禁止题材错位(都市文取玄幻书名会导致读者流失)
-- 参考热榜书名风格:俏皮、通俗、有记忆点
-
-简介方法论(300字内,三种写法任选其一):
-1. 冲突开篇法:第一句抛困境/冲突,第二句亮金手指/核心能力,第三句留悬念
-2. 高度概括法:只挑主线概括(不是全篇概括),必须留悬念
-3. 小剧场法:提炼故事中最经典的桥段,作为引子
-
-简介核心原则:
-- 简介 = 产品宣传语,必须让读者产生"我要点开看"的冲动
-- 可以从剧情设定、人设、或某个精彩片段切入
-- 必须有噱头(如"凡是被写在笔记本上的名字,最后都得死")`;
+【book_rules.md 全文】
+${reviseFrom.bookRules || "(无)"}
 
-    const volumeOutlinePrompt = resolvedLanguage === "en"
-      ? `Volume plan. For each volume include: title, chapter range, core conflict, key turning points, and payoff goal
+【character_matrix.md 全文】
+${reviseFrom.characterMatrix || "(无)"}
 
-### Golden First Three Chapters Rule
-- Chapter 1: throw the core conflict immediately; no large background dump
-- Chapter 2: show the core edge / ability / leverage that answers Chapter 1's pressure
-- Chapter 3: establish the first concrete short-term goal that gives readers a reason to continue`
-      : `卷纲规划,每卷包含:卷名、章节范围、核心冲突、关键转折、收益目标
+你的任务:
+1. 把 story_bible 的内容重新组织成 4 段段落式 story_frame.md(主题 / 核心冲突 / 世界观底色 / 终局方向)
+2. 把 volume_outline 的内容重新组织成 5 段 volume_map.md + 末尾 1 段节奏原则
+3. 把 character_matrix 里的每个角色拆成一份 roles/主要角色/<name>.md 或 roles/次要角色/<name>.md
+
+严格约束:
+- 世界观设定、角色设定、主线走向、已埋下的伏笔一个字都不能丢
+- 如果原内容里有 bullet 点,把它们连成段落;不要只是把 bullet 转成句号分隔
+- 主次角色的判断依据:character_matrix 里已标注的如果有,沿用;没有的话,把在 volume_outline 里出现频次高、或者承担主线冲突的列为主要,其余为次要
+- 基调 / 语气不要改变
+- 如果原内容里留有空白或未展开项,保留为空,不要主动补全
+
+用户额外要求:
+${reviseFrom.userFeedback || "(无)"}
+
+---
+
+`;
+  }
+
+  // -------------------------------------------------------------------------
+  // Phase 5 段落式 prompt — zh
+  // -------------------------------------------------------------------------
+  private buildChineseFoundationPrompt(
+    book: BookConfig,
+    gp: GenreProfile,
+    genreBody: string,
+    contextBlock: string,
+    reviewFeedbackBlock: string,
+    numericalBlock: string,
+    powerBlock: string,
+    eraBlock: string,
+  ): string {
+    return `你是这本书的总架构师。你的唯一输出是**段落密度的基础设定**——不是表格、不是 schema、不是条目化 bullet。这本书的"灵气"从你这里来。你的段落密度决定了后面 planner 能不能读出"稀疏 memo",writer 能不能写出活人,reviewer 能不能校准硬伤。${contextBlock}${reviewFeedbackBlock}
+
+## 书籍元信息
+- 平台:${book.platform}
+- 题材:${gp.name}(${book.genre})
+- 目标章数:${book.targetChapters}章
+- 每章字数:${book.chapterWordCount}字
+- 标题:${book.title}
+
+## 题材底色
+${genreBody}
+
+## 产出约束(硬性)
+${numericalBlock}
+${powerBlock}
+${eraBlock}
+
+## 输出结构(5 个 SECTION,严格按 === SECTION: === 分块,不要漏任何一块)
+
+## 去重铁律(必读)
+禁止在多段里重复同一事实。主角的完整角色线只写在 roles;世界铁律只写在 story_frame.世界观底色;节奏原则只写在 volume_map 最后一段;角色当前现状只写在 roles.当前现状;初始钩子只写在 pending_hooks(startChapter=0 行)。**如果本书是年代文/历史同人/都市重生等需要年份、季节、重大历史事件作为锚点的题材**,把环境/时代锚自然织进 story_frame.世界观底色("1985 年 7 月,非典刚过"这类);**修仙/玄幻/系统等没有真实年份的题材直接省略**,不要硬凑。如果一个段落写了另一段的内容,删掉。
+
+## 预算(超预算必删)
+- story_frame ≤ 3000 字
+- volume_map ≤ 5000 字
+- roles 总 ≤ 8000 字
+- book_rules ≤ 500 字(仅 YAML)
+- pending_hooks ≤ 2000 字
+
+=== SECTION: story_frame ===
+
+这是段落式骨架。**4 段**,每段约 600-900 字,不要写表格,不要写 bullet list,写成能被人读下去的段落。段落标题用 \`## \` 开头,段落内部是正经段落。**主角的完整角色线不写在本 section;它的权威来源是 roles/主要角色/<主角>.md。** 本段只需一句指针:"本书主角是 X,完整角色线详见 roles/主要角色/X.md"。
+
+### 段 1:主题与基调
+写这本书到底讲的是什么——不是"讲主角如何从弱到强"这种空话,而是具体的命题("一个被时代按在泥里的人,如何选择不被改写"、"当所有人都在撒谎时,坚持记录真相要付出什么代价")。主题下面跟着基调——温情冷冽悲壮肃杀,哪一种?为什么是这种而不是另一种?结尾用一句话指向主角并引向 roles(例:"本书主角是林辞,完整角色线详见 roles/主要角色/林辞.md")。
+
+### 段 2:核心冲突与对手定性
+这本书的主要矛盾是什么?不是"正邪对抗",而是"因为 A 相信 X、B 相信 Y,所以他们一定会在某件事上对撞"。主要对手是谁(至少 2 个:一个显性对手 + 一个结构性对手/体制),他们的动机从哪里长出来。对手不是工具,对手有自己的逻辑。
+
+### 段 3:世界观底色(铁律 + 质感 + 本书专属规则)
+这个世界的运行规则是什么?3-5 条**不可违反的铁律**——以段落形式写出,不要 bullet。这个世界的质感是什么——湿的还是干的、快的还是慢的、噪的还是静的?给 writer 一个明确的感官锚。**这一段同时承担原先 book_rules 正文里写的"叙事视角 / 本书专属规则 / 核心冲突驱动"等内容**——全部合并到这里写一次就够,不要再去 book_rules 重复。
+
+### 段 4:终局方向
+这本书最后一章大概是什么感觉——不是"主角登顶"、"大结局"这种套话,而是**最后一个镜头**大致长什么样。主角最后在哪、做什么、身边有谁、心里想什么。这是给全书所有后面的规划一个远方靶子。
+
+=== SECTION: volume_map ===
+
+这是分卷段落式地图,**5 段主体 + 1 段节奏原则尾段**。**关键要求:只写到卷级内容**——写清楚每卷的主题、情绪曲线、卷间钩子、角色阶段目标、卷尾不可逆事件。**禁止指定具体章号任务**(不要写"第 17 章让他回家"这种章级布局)。章级规划是 Phase 3 planner 的职责,架构师只搭骨架、不编章目。
+
+### 段 1:各卷主题与情绪曲线
+有几卷?每卷的主题一句话,每卷的情绪曲线一段(哪里压、哪里爽、哪里冷、哪里暖)。不要机械的"第一卷打小怪第二卷打大怪",写情绪的流动。
+
+### 段 2:卷间钩子与回收承诺
+第 1 卷埋什么钩子、在哪一卷回收;第 2 卷埋什么、在哪一卷回收。段落式写,不要表格。**只写卷级**(如"第 1 卷埋的身世之谜在第 3 卷回收"),不要写具体章号。
+
+### 段 3:角色阶段性目标
+主角在第 1 卷末要到什么状态?第 2 卷末?每一卷结束时主角的身份/关系/能力/心境应该是什么。次要角色的阶段性变化也要点到(师父在第 2 卷会死、对手在第 3 卷会黑化等)。写阶段性,不写完整角色线(完整角色线在 roles)。
+
+### 段 4:卷尾必须发生的改变
+每一卷最后一章必须发生什么不可逆的事——权力结构改变、关系破裂、秘密暴露、主角身份重定位。写段落,一卷一段。**只写"必须发生什么",不指定是第几章**。
+
+### 段 5:节奏原则(具体化 + 通用)
+**这是节奏原则的唯一归宿,不再有独立 rhythm_principles section。** 本段输出 6 条节奏原则。**至少 3 条必须具体化到本书**(例:"前 30 章每 5 章一个小爽点"),其余可保留通用原则(例:"拒绝机械降神"、"高潮前 3-5 章埋伏笔")。具体化 + 通用混合是合法的。反面例子:"节奏要张弛有度"(废话)。正面例子:"前 30 章每 5 章一个小爽点,且小爽点必须落在章末 300 字内"。6 条各写 2-3 句,覆盖(顺序不强制、可替换同权重议题):
+1. 高潮间距——本书大高潮之间最长多少章?(具体化优先)
+2. 喘息频率——高压段多长必须插一章喘息?喘息章承担什么任务?
+3. 钩子密度——每章章末留钩数量,主钩最多允许悬多少章?
+4. 信息释放节奏——主线信息在前 1/3、中段、后 1/3 分别释放多少比例?(可通用)
+5. 爽点节奏——爽点间距多少章一个?什么类型为主?(具体化优先)
+6. 情感节点递进——情感关系每多少章必须有一次实质推进?
 
 ### 黄金三章法则(前三章必须遵循)
 - 第1章:抛出核心冲突(主角立即面临困境/危机/选择),禁止大段背景灌输
 - 第2章:展示金手指/核心能力(主角如何应对第1章的困境),让读者看到爽点预期
-- 第3章:明确短期目标(主角确立第一个具体可达成的目标),给读者追读理由`;
+- 第3章:明确短期目标(主角确立第一个具体可达成的目标),给读者追读理由
 
-    const bookRulesPrompt = resolvedLanguage === "en"
-      ? `Generate book_rules.md as YAML frontmatter plus narrative guidance:
-\`\`\`
----
-version: "1.0"
-protagonist:
-  name: (protagonist name)
-  personalityLock: [(3-5 personality keywords)]
-  behavioralConstraints: [(3-5 behavioral constraints)]
-genreLock:
-  primary: ${book.genre}
-  forbidden: [(2-3 forbidden style intrusions)]
-${gp.numericalSystem ? `numericalSystemOverrides:
-  hardCap: (decide from the setting)
-  resourceTypes: [(core resource types)]` : ""}
-prohibitions:
-  - (3-5 book-specific prohibitions)
-chapterTypesOverride: []
-fatigueWordsOverride: []
-additionalAuditDimensions: []
-enableFullCastTracking: false
----
+=== SECTION: roles ===
 
-## Narrative Perspective
-(Describe the narrative perspective and style)
+一人一卡。**主角卡是本书角色线的唯一权威来源**——story_frame 不再写主角的完整角色线,writer/planner 都从这里读。用以下格式分隔:
 
-## Core Conflict Driver
-(Describe the book's core conflict and propulsion)
-\`\`\``
-      : `生成 book_rules.md 格式的 YAML frontmatter + 叙事指导,包含:
+---ROLE---
+tier: major
+name: <角色名>
+---CONTENT---
+(这里写段落式角色卡,下面的小标题必须全部出现,每段至少 3 行正经段落,不要写表格)
+
+## 核心标签
+(3-5 个关键词 + 一句话为什么是这些词)
+
+## 反差细节
+(1-2 个与核心标签反差的具体细节——"冷酷杀手但会给流浪猫留鱼骨"。反差细节是人物立体化的公式,必须有。)
+
+## 人物小传(过往经历)
+(一段段落,说这个人怎么变成现在这样。童年/重大事件/塑造性格的那件事。只写关键过往,简版。)
+
+## 主角线(起点 → 终点 → 代价)
+**只有主角必须写本段;其他 major 角色如果分量重也可以写,否则略过。**主角从哪里出发(身份、处境、核心缺陷、一开始最想要什么),到哪里落脚(最终变成什么样的人、拿到/失去什么),为了这个落脚他付出了什么不可逆的代价(关系、身体、信念、某段过去)。不要只写"变强"这种平面变化,要写**内在的位移**。写足写实。
+
+## 当前现状(第 0 章初始状态)
+(第 0 章时他在哪、做什么、处境如何、最近最烦心的事。**只写角色个人处境**——初始钩子写在 pending_hooks 的 startChapter=0 行;环境/时代锚(如果是需要年份的题材)织进 story_frame.世界观底色。不再有独立的 current_state section。)
+
+## 关系网络
+(与主角、与其他重要角色的关系——一句话一条,关系不是标签是动态。)
+
+## 内在驱动
+(他想要什么、为什么想要、愿意付出什么代价。)
+
+## 成长变化
+(他在这本书里会经历什么内在位移——变好变坏变复杂,落在哪里。非主角可短可长。)
+
+---ROLE---
+tier: major
+name: <下一个主要角色>
+---CONTENT---
+...
+
+(主要角色至少 3 个:主角 + 主要对手 + 主要协作者。建议 2-3 主 + 2-3 辅,不要灌水。质量 > 数量。)
+
+---ROLE---
+tier: minor
+name: <次要角色名>
+---CONTENT---
+(次要角色简化版,只需要 4 个小标题:核心标签 / 反差细节 / 当前现状 / 与主角关系,每段 1-2 行即可)
+
+(次要角色 3-5 个,按出场密度给。)
+
+=== SECTION: book_rules ===
+
+**只输出 YAML frontmatter 一块——零段落正文。** 所有的"叙事视角 / 本书专属规则 / 核心冲突驱动"等段落已经合并到 story_frame.世界观底色,不要在这里重复写。
 \`\`\`
 ---
 version: "1.0"
@@ -168,175 +308,382 @@ fatigueWordsOverride: []
 additionalAuditDimensions: []
 enableFullCastTracking: false
 ---
+\`\`\`
 
-## 叙事视角
-(描述本书叙事视角和风格)
-
-## 核心冲突驱动
-(描述本书的核心矛盾和驱动力)
-\`\`\``;
-
-    const currentStatePrompt = resolvedLanguage === "en"
-      ? `Initial state card (Chapter 0), include:
-| Field | Value |
-| --- | --- |
-| Current Chapter | 0 |
-| Current Location | (starting location) |
-| Protagonist State | (initial condition) |
-| Current Goal | (first goal) |
-| Current Constraint | (initial constraint) |
-| Current Alliances | (initial relationships) |
-| Current Conflict | (first conflict) |`
-      : `初始状态卡(第0章),包含:
-| 字段 | 值 |
-|------|-----|
-| 当前章节 | 0 |
-| 当前位置 | (起始地点) |
-| 主角状态 | (初始状态) |
-| 当前目标 | (第一个目标) |
-| 当前限制 | (初始限制) |
-| 当前敌我 | (初始关系) |
-| 当前冲突 | (第一个冲突) |`;
-
-    const pendingHooksPrompt = resolvedLanguage === "en"
-      ? `Initial hook pool (Markdown table):
-| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes |
+=== SECTION: pending_hooks ===
 
-Rules for the hook table:
-- Column 5 must be a pure chapter number, never natural-language description
-- During book creation, all planned hooks are still unapplied, so last_advanced_chapter = 0
-- Column 7 must be one of: immediate / near-term / mid-arc / slow-burn / endgame
-- If you want to describe the initial clue/signal, put it in notes instead of column 5`
-      : `初始伏笔池(Markdown表格):
+初始伏笔池(Markdown表格,8 列基础版):
 | hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |
 
 伏笔表规则:
 - 第5列必须是纯数字章节号,不能写自然语言描述
 - 建书阶段所有伏笔都还没正式推进,所以第5列统一填 0
 - 第7列必须填写:立即 / 近期 / 中程 / 慢烧 / 终局 之一
-- 如果要说明“初始线索/最初信号”,写进备注,不要写进第5列`;
+- 初始线索放备注列,不放第5列
+- **初始世界状态 / 初始敌我关系** 如果有关键信息(例如"主角身上带着父亲的笔记本"、"体制已经开始监视码头"),可以作为 startChapter=0 的种子行录入,备注列说明其"初始状态"属性
+
+## 最后强调
+- 符合${book.platform}平台口味、${gp.name}题材特征
+- 主角人设鲜明、行为边界清晰
+- 伏笔前后呼应、配角有独立动机不是工具人
+- **story_frame / volume_map / roles 必须是段落密度,不要退化成 bullet**
+- **book_rules 只留 YAML,不要写段落正文**
+- **不要输出 rhythm_principles 或 current_state 独立 section**——节奏原则合并进 volume_map 尾段;角色初始状态写在 roles.当前现状,初始钩子写在 pending_hooks(startChapter=0 行),环境/时代锚(仅历史/年代/都市重生等需要年份的题材)织进 story_frame.世界观底色,不要硬凑`;
+  }
 
-    const finalRequirementsPrompt = resolvedLanguage === "en"
-      ? `Generated content must:
-1. Fit the ${book.platform} platform taste
-2. Fit the ${gp.name} genre traits
-${numericalBlock}
-${powerBlock}
-${eraBlock}
-3. Give the protagonist a clear personality and behavioral boundaries
-4. Keep hooks and payoffs coherent
-5. Make supporting characters independently motivated rather than pure tools`
-      : `生成内容必须:
-1. 符合${book.platform}平台口味
-2. 符合${gp.name}题材特征
+  // -------------------------------------------------------------------------
+  // Phase 5 段落式 prompt — en
+  // -------------------------------------------------------------------------
+  private buildEnglishFoundationPrompt(
+    book: BookConfig,
+    gp: GenreProfile,
+    genreBody: string,
+    contextBlock: string,
+    reviewFeedbackBlock: string,
+    numericalBlock: string,
+    powerBlock: string,
+    eraBlock: string,
+  ): string {
+    return `You are the architect of this book. Your only job is to produce **prose-density foundation design** — not tables, not schema, not bullet lists. The book's aura comes from your prose density: the Phase 3 planner reads sparse memos out of your volume_map only if it was written to chapter-level prose; the writer only produces living characters because your role sheets carry contrast details; the reviewer only catches hard errors because your story_frame set the tonal anchors.${contextBlock}${reviewFeedbackBlock}
+
+## Book metadata
+- Platform: ${book.platform}
+- Genre: ${gp.name} (${book.genre})
+- Target chapters: ${book.targetChapters}
+- Chapter length: ${book.chapterWordCount}
+- Title: ${book.title}
+
+## Genre body
+${genreBody}
+
+## Output constraints
 ${numericalBlock}
 ${powerBlock}
 ${eraBlock}
-3. 主角人设鲜明,有明确行为边界
-4. 伏笔前后呼应,不留悬空线
-5. 配角有独立动机,不是工具人`;
 
-    const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新的${gp.name}小说生成完整的基础设定。${contextBlock}${reviewFeedbackBlock}
+## Output contract (5 === SECTION: === blocks)
 
-要求:
-- 平台:${book.platform}
-- 题材:${gp.name}(${book.genre})
-- 目标章数:${book.targetChapters}章
-- 每章字数:${book.chapterWordCount}字
+## Deduplication rule (MANDATORY)
+Do not duplicate the same fact across sections. The protagonist's full character arc lives only in roles; world hard-rules live only in story_frame; rhythm principles live only in the last paragraph of volume_map; character initial status lives only in roles.Current_State; initial hooks live only in pending_hooks (start_chapter=0 rows). **When the book is period fiction / historical fanfic / urban reincarnation** — anything pinned to a real year, season, or historic marker — weave the environment/era anchor into story_frame's world-tonal-ground paragraph. **For cultivation / high-fantasy / system genres that have no real-world year, skip it entirely** — do not fabricate an era anchor. If a section repeats content that belongs elsewhere, delete it.
 
-## 题材特征
+## Output budget (over-budget means cut)
+- story_frame ≤ 3000 chars
+- volume_map ≤ 5000 chars
+- roles ≤ 8000 chars total
+- book_rules ≤ 500 chars (YAML only)
+- pending_hooks ≤ 2000 chars
 
-${genreBody}
+=== SECTION: story_frame ===
 
-## 生成要求
+Four prose sections, ~600-900 chars each. No tables. No bullet lists. Real paragraphs. **Do NOT write the protagonist's full character arc here** — that is owned by roles/主要角色/<protagonist>.md. Use a single-line pointer inside this block.
 
-你需要生成以下内容,每个部分用 === SECTION: <name> === 分隔:
+## 01_Theme_and_Tonal_Ground
+What is this book actually about — not "hero grows from weak to strong" (empty), but a concrete proposition. Then the tonal ground: warm / cold / fierce / severe — which, and why this and not another. End with a one-line pointer to the protagonist role file.
 
-=== SECTION: story_bible ===
-${storyBiblePrompt}
+## 02_Core_Conflict_and_Opponent
+The book's main tension — not "good vs evil" but "because A believes X and B believes Y, they will inevitably collide on Z". At least two opponents: one visible, one structural/systemic. Opponents have their own logic.
 
-=== SECTION: volume_outline ===
-${volumeOutlinePrompt}
+## 03_World_Tonal_Ground (hard rules + sensory tone + book-specific rules)
+The world's operating rules. 3-5 unbreakable laws written as prose, not bullets. Sensory texture: wet or dry, fast or slow, noisy or quiet — give the writer an anchor. **This paragraph also absorbs the narrative prose that used to live in book_rules** (narrative perspective, core conflict driver, book-specific rules). Write them all here once. Do not repeat them in book_rules.
 
-=== SECTION: book_rules ===
-${bookRulesPrompt}
+## 04_Endgame_Direction
+What the last chapter roughly feels like. The final shot: where, doing what, around whom, thinking what. A distant target for every planner call downstream.
 
-=== SECTION: current_state ===
-${currentStatePrompt}
+=== SECTION: volume_map ===
 
-=== SECTION: pending_hooks ===
-${pendingHooksPrompt}
+Prose volume map, **5 sections + 1 closing rhythm paragraph**. **Critical requirement: stay at volume-level prose only**. No chapter-level task assignment.
 
-${finalRequirementsPrompt}`;
+## 01_Volume_Themes_and_Emotional_Curves
+How many volumes? Each volume's theme in one sentence; each volume's emotional curve as a paragraph.
 
-    const langPrefix = resolvedLanguage === "en"
-      ? `【LANGUAGE OVERRIDE】ALL output (story_bible, volume_outline, book_rules, current_state, pending_hooks) MUST be written in English. Character names, place names, and all prose must be in English. The === SECTION: === tags remain unchanged.\n\n`
-      : "";
-    const userMessage = resolvedLanguage === "en"
-      ? `Generate the complete foundation for a ${gp.name} novel titled "${book.title}". Write everything in English.`
-      : `请为标题为"${book.title}"的${gp.name}小说生成完整基础设定。`;
+## 02_Cross_Volume_Hooks_and_Payoff_Promises
+Volume 1 plants hook A, paid off in volume N; volume 2 plants hook B, paid off in volume M. Prose, not tables. **Stay at volume-level**.
 
-    const response = await this.chat([
-      { role: "system", content: langPrefix + systemPrompt },
-      { role: "user", content: userMessage },
-    ], { temperature: 0.8, maxTokens: 16384 });
+## 03_Character_Stage_Goals
+Protagonist's state at end of vol 1, vol 2, ... Supporting characters' stage changes. Stage goals only — full character arc lives in roles.
 
-    return this.parseSections(response.content);
+## 04_Volume_End_Mandatory_Changes
+Each volume's last chapter must contain an irreversible event. Prose, one paragraph per volume.
+
+## 05_Rhythm_Principles (concrete + universal)
+**Single home for rhythm principles — no separate rhythm_principles section.** 6 rhythm principles. **At least 3 must be concretized for this book**; the rest may stay as universal rules.
+
+### Golden First Three Chapters Rule
+- Chapter 1: throw the core conflict immediately; no large background dump
+- Chapter 2: show the core edge / ability / leverage that answers Chapter 1's pressure
+- Chapter 3: establish the first concrete short-term goal that gives readers a reason to continue
+
+=== SECTION: roles ===
+
+One-file-per-character prose. **The protagonist card is the single source of truth for the protagonist's full character arc** — story_frame no longer carries it, and writer/planner both read it here.
+
+---ROLE---
+tier: major
+name: <character name>
+---CONTENT---
+## Core_Tags
+(3-5 tags + one sentence on why)
+
+## Contrast_Detail
+(1-2 concrete details that contradict the core tags.)
+
+## Back_Story
+(Prose paragraph — how this person became who they are.)
+
+## Protagonist_Arc (start → end → cost)
+**Mandatory for the protagonist; optional for other majors with substantial arcs.** Internal displacement, not just growth.
+
+## Current_State (initial state at chapter 0)
+(Character-only: initial hooks go in pending_hooks start_chapter=0 rows; environment/era anchors weave into story_frame.)
+
+## Relationship_Network
+(With protagonist, with other majors. One line each.)
+
+## Inner_Driver
+(What they want, why, what they're willing to pay.)
+
+## Growth_Arc
+(Internal displacement across the book.)
+
+---ROLE---
+tier: major
+name: <next major>
+---CONTENT---
+...
+
+(Aim for 2-3 majors + 2-3 supporting majors. Quality over quantity.)
+
+---ROLE---
+tier: minor
+name: <minor name>
+---CONTENT---
+(Simplified: Core_Tags / Contrast_Detail / Current_State / Relationship_to_Protagonist only, 1-2 lines each.)
+
+(3-5 minors.)
+
+=== SECTION: book_rules ===
+
+**Output ONLY the YAML frontmatter block — zero prose.** All narrative guidance moved into story_frame.03_World_Tonal_Ground.
+\`\`\`
+---
+version: "1.0"
+protagonist:
+  name: (protagonist name)
+  personalityLock: [(3-5 personality keywords)]
+  behavioralConstraints: [(3-5 behavioral constraints)]
+genreLock:
+  primary: ${book.genre}
+  forbidden: [(2-3 forbidden style intrusions)]
+${gp.numericalSystem ? `numericalSystemOverrides:
+  hardCap: (decide from setting)
+  resourceTypes: [(core resource types)]` : ""}
+prohibitions:
+  - (3-5 book-specific prohibitions)
+chapterTypesOverride: []
+fatigueWordsOverride: []
+additionalAuditDimensions: []
+enableFullCastTracking: false
+---
+\`\`\`
+
+=== SECTION: pending_hooks ===
+
+Initial hook pool (Markdown table, 8-column base):
+| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes |
+
+Rules:
+- Column 5 is a pure chapter number, not narrative description
+- At book creation all planned hooks have last_advanced_chapter = 0
+- Column 7 must be: immediate / near-term / mid-arc / slow-burn / endgame
+- Put initial signal text in notes, not column 5
+- **Initial world / alliance state**: any load-bearing initial condition can be seeded as a start_chapter=0 row with a notes tag indicating its initial-state nature
+
+## Final emphasis
+- Fit ${book.platform} platform taste and ${gp.name} genre traits
+- Protagonist persona clear with sharp behavioral boundaries
+- Hooks planted with payoff promises; supporting characters have independent motivation
+- **story_frame / volume_map / roles must be prose density — no bullet-list degradation**
+- **book_rules is YAML only — no prose body**
+- **Do NOT emit rhythm_principles or current_state as separate sections** — rhythm principles live in the last paragraph of volume_map; character initial status goes in roles.Current_State; initial hooks go in pending_hooks (start_chapter=0 rows); environment/era anchors (only when the genre has a real year) are woven into story_frame`;
   }
 
+  /**
+   * Write architect foundation output to disk.
+   *
+   * @param mode
+   *   - "init"(默认):首次建书。写架构稿 + 初始化所有运行时状态文件
+   *     (current_state / pending_hooks / particle_ledger / subplot_board /
+   *     emotional_arcs)为空模板。
+   *   - "revise":在已有书上重写架构稿。**只改架构稿相关文件**——outline/ /
+   *     roles/ / 4 个 legacy shim——**完全不动运行时状态文件**。这和
+   *     context-transform 注入给 LLM 的 upgrade hint 承诺"只改架构稿不动已写
+   *     章节"一致;如果在 revise 模式下触动运行时文件,会把 consolidator 累积
+   *     的章节状态、伏笔推进、资源账本等全部重置。
+   */
   async writeFoundationFiles(
     bookDir: string,
     output: ArchitectOutput,
     numericalSystem: boolean = true,
     language: "zh" | "en" = "zh",
+    mode: "init" | "revise" = "init",
   ): Promise<void> {
     const storyDir = join(bookDir, "story");
-    await mkdir(storyDir, { recursive: true });
-
-    const writes: Array<Promise<void>> = [
-      writeFile(join(storyDir, "story_bible.md"), output.storyBible, "utf-8"),
-      writeFile(join(storyDir, "volume_outline.md"), output.volumeOutline, "utf-8"),
-      writeFile(join(storyDir, "book_rules.md"), output.bookRules, "utf-8"),
-      writeFile(join(storyDir, "current_state.md"), output.currentState, "utf-8"),
-      writeFile(join(storyDir, "pending_hooks.md"), output.pendingHooks, "utf-8"),
-    ];
-
-    if (numericalSystem) {
-      writes.push(
-        writeFile(
+    const outlineDir = join(storyDir, "outline");
+    const rolesDir = join(storyDir, "roles");
+    const rolesMajorDir = join(rolesDir, "主要角色");
+    const rolesMinorDir = join(rolesDir, "次要角色");
+
+    await Promise.all([
+      mkdir(storyDir, { recursive: true }),
+      mkdir(outlineDir, { recursive: true }),
+      mkdir(rolesMajorDir, { recursive: true }),
+      mkdir(rolesMinorDir, { recursive: true }),
+    ]);
+
+    const writes: Array<Promise<void>> = [];
+
+    const storyFrame = output.storyFrame ?? "";
+    const volumeMap = output.volumeMap ?? "";
+    const rhythmPrinciples = output.rhythmPrinciples ?? "";
+    const roles = output.roles ?? [];
+    const isPhase5Output = storyFrame.trim().length > 0;
+
+    // debug: 让排查时能一眼看出 LLM 按哪套格式输出、落到哪套文件布局。
+    // 如果用户新建书后发现只有 story_bible.md / 没有 outline/,看这行日志能
+    // 确认是 LLM 没按新 prompt 输出(走了 legacy 分支),而不是 writeFoundationFiles
+    // 本身的 bug。
+    this.ctx.logger?.info(
+      `[architect] writeFoundationFiles layout=${isPhase5Output ? "phase5" : "legacy"} ` +
+      `storyFrame=${storyFrame.length}chars volumeMap=${volumeMap.length}chars roles=${roles.length}`,
+    );
+
+    // revise 模式 + LLM 回退 legacy 输出 → 必须抛错,不写任何文件。
+    // 原因:如果走 legacy 分支覆盖 story_bible.md 等 flat 文件,而 outline/
+    // 和 roles/ 保持不变,会让 Phase 5 书进入"outline 新 + shim 老 + roles
+    // 残留"的混乱状态;读取端的 fallback 逻辑会读到互相矛盾的内容。
+    // 抛错让用户立刻感知 LLM 响应异常,而不是默默破坏书的数据完整性。
+    if (mode === "revise" && !isPhase5Output) {
+      throw new Error(
+        "Architect revise mode produced legacy-format output (storyFrame empty). " +
+        "This likely means the LLM didn't follow the reviseFrom prompt. " +
+        "The book's architecture files have NOT been modified. " +
+        "Check prompt / model / temperature and try again.",
+      );
+    }
+
+    // revise 模式下先清空旧 role 文件,再写本次 architect 输出——避免改名 / 删除 /
+    // 合并角色后的旧卡片残留被 readRoleCards 当作有效角色继续注入(见 Bug 3)。
+    // 备份由上游 runner.reviseFoundation 在调用前完成,这里可以安全清空。
+    // init 模式下目录本来就是空的,不需要清。
+    // 放在 if (isPhase5Output) 外面,保证所有 revise 场景都清。
+    if (mode === "revise") {
+      await rm(rolesMajorDir, { recursive: true, force: true });
+      await rm(rolesMinorDir, { recursive: true, force: true });
+      await mkdir(rolesMajorDir, { recursive: true });
+      await mkdir(rolesMinorDir, { recursive: true });
+    }
+
+    if (isPhase5Output) {
+      // book_rules 的 YAML frontmatter 提取后拼到 story_frame.md 顶部,作为权威位置。
+      const { frontmatter: bookRulesFrontmatter, body: bookRulesBody } =
+        extractYamlFrontmatter(output.bookRules);
+      const storyFrameWithFrontmatter = bookRulesFrontmatter
+        ? `${bookRulesFrontmatter}\n\n${storyFrame.trim()}\n`
+        : storyFrame;
+
+      // Phase 5 权威文件
+      writes.push(writeFile(join(outlineDir, "story_frame.md"), storyFrameWithFrontmatter, "utf-8"));
+      writes.push(writeFile(join(outlineDir, "volume_map.md"), volumeMap, "utf-8"));
+      if (rhythmPrinciples.trim()) {
+        const rhythmFileName = language === "en" ? "rhythm_principles.md" : "节奏原则.md";
+        writes.push(writeFile(join(outlineDir, rhythmFileName), rhythmPrinciples, "utf-8"));
+      }
+
+      // 一人一卡
+      for (const role of roles) {
+        const targetDir = role.tier === "major" ? rolesMajorDir : rolesMinorDir;
+        const safeName = role.name.replace(/[/\\:*?"<>|]/g, "_").trim();
+        if (!safeName) continue;
+        writes.push(writeFile(join(targetDir, `${safeName}.md`), role.content, "utf-8"));
+      }
+
+      // Legacy shim 文件
+      writes.push(writeFile(
+        join(storyDir, "story_bible.md"),
+        this.buildStoryBibleShim(storyFrameWithFrontmatter, language),
+        "utf-8",
+      ));
+      writes.push(writeFile(
+        join(storyDir, "volume_outline.md"),
+        volumeMap,
+        "utf-8",
+      ));
+      writes.push(writeFile(
+        join(storyDir, "character_matrix.md"),
+        this.buildCharacterMatrixShim(roles, language),
+        "utf-8",
+      ));
+      writes.push(writeFile(
+        join(storyDir, "book_rules.md"),
+        this.buildBookRulesShim(bookRulesBody, language),
+        "utf-8",
+      ));
+    } else {
+      // Legacy 输出路径(仅 init 模式——revise + legacy 已在上面抛错):
+      // LLM 还按老 prompt 输出 story_bible / volume_outline,走 Phase 4 落盘方式。
+      writes.push(writeFile(join(storyDir, "story_bible.md"), output.storyBible, "utf-8"));
+      writes.push(writeFile(join(storyDir, "volume_outline.md"), output.volumeOutline, "utf-8"));
+      writes.push(writeFile(join(storyDir, "book_rules.md"), output.bookRules, "utf-8"));
+      writes.push(writeFile(
+        join(storyDir, "character_matrix.md"),
+        language === "en"
+          ? "# Character Matrix\n\n<!-- One ## section per character. Add new characters as new ## blocks. -->\n"
+          : "# 角色矩阵\n\n<!-- 每个角色一个 ## 块,新角色追加新 ## 即可。 -->\n",
+        "utf-8",
+      ));
+    }
+
+    // 运行时状态文件——**只在 init 模式写**。revise 模式下这些文件已经存在且
+    // 被 consolidator 累积了章节状态(伏笔进度、角色位置、资源账本、情感曲线
+    // 等),重写会把已写章节的真实状态全部清零,违反 context-transform 里给
+    // LLM 的承诺"升级只改架构稿,不动已写的章节"(见 Bug 1)。
+    if (mode === "init") {
+      // current_state.md — 架构师不再产出结构化初始状态,给占位 seed;运行时由
+      // consolidator 每章追加。如果 output 里带了内容(legacy 输出或 reviser
+      // 生成),直接用。
+      const currentStateSeed = output.currentState?.trim()
+        ? output.currentState
+        : (language === "en"
+            ? "# Current State\n\n> Seeded at book creation. Runtime state is appended by the consolidator after each chapter. Initial per-character state lives in roles/*.Current_State; load-bearing initial world facts live in pending_hooks rows with start_chapter=0.\n"
+            : "# 当前状态\n\n> 建书时占位。运行时每章之后由 consolidator 追加最新状态。每个角色的初始状态详见 roles/*.当前现状;承重的初始世界设定见 pending_hooks 里 startChapter=0 的行。\n");
+      writes.push(writeFile(join(storyDir, "current_state.md"), currentStateSeed, "utf-8"));
+      writes.push(writeFile(join(storyDir, "pending_hooks.md"), output.pendingHooks, "utf-8"));
+
+      // 运行时 append log 文件,下游 state-validator / consolidator 依赖这些存在。
+      if (numericalSystem) {
+        writes.push(writeFile(
           join(storyDir, "particle_ledger.md"),
           language === "en"
             ? "# Resource Ledger\n\n| Chapter | Opening Value | Source | Integrity | Delta | Closing Value | Evidence |\n| --- | --- | --- | --- | --- | --- | --- |\n| 0 | 0 | Initialization | - | 0 | 0 | Initial book state |\n"
             : "# 资源账本\n\n| 章节 | 期初值 | 来源 | 完整度 | 增量 | 期末值 | 依据 |\n|------|--------|------|--------|------|--------|------|\n| 0 | 0 | 初始化 | - | 0 | 0 | 开书初始 |\n",
           "utf-8",
-        ),
-      );
-    }
-
-    // Initialize new truth files
-    writes.push(
-      writeFile(
+        ));
+      }
+      writes.push(writeFile(
         join(storyDir, "subplot_board.md"),
         language === "en"
           ? "# Subplot Board\n\n| Subplot ID | Subplot | Related Characters | Start Chapter | Last Active Chapter | Chapters Since | Status | Progress Summary | Payoff ETA |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n"
           : "# 支线进度板\n\n| 支线ID | 支线名 | 相关角色 | 起始章 | 最近活跃章 | 距今章数 | 状态 | 进度概述 | 回收ETA |\n|--------|--------|----------|--------|------------|----------|------|----------|---------|\n",
         "utf-8",
-      ),
-      writeFile(
+      ));
+      writes.push(writeFile(
         join(storyDir, "emotional_arcs.md"),
         language === "en"
           ? "# Emotional Arcs\n\n| Character | Chapter | Emotional State | Trigger Event | Intensity (1-10) | Arc Direction |\n| --- | --- | --- | --- | --- | --- |\n"
           : "# 情感弧线\n\n| 角色 | 章节 | 情绪状态 | 触发事件 | 强度(1-10) | 弧线方向 |\n|------|------|----------|----------|------------|----------|\n",
         "utf-8",
-      ),
-      writeFile(
-        join(storyDir, "character_matrix.md"),
-        language === "en"
-          ? "# Character Matrix\n\n<!-- One ## section per character. Add new characters as new ## blocks. -->\n"
-          : "# 角色矩阵\n\n<!-- 每个角色一个 ## 块,新角色追加新 ## 即可。 -->\n",
-        "utf-8",
-      ),
-    );
+      ));
+    }
 
     await Promise.all(writes);
   }
@@ -550,7 +897,7 @@ The volume_outline should naturally extend the existing narrative arc. Continue
 3. **场景新鲜度**:续写部分至少50%的关键场景发生在导入章节未出现的地点或情境中
 4. **不重复会议**:如果导入章节以会议/讨论结束,续写必须从行动开始,不能再开一轮会`
       : `## 续写方向
-卷纲应自然延续已有叙事弧线。从导入章节的结尾处接续——推进现有冲突、兑现已埋伏笔、引入从当前局势中有机产生的新变数。不要回顾已知信息。`;
+卷纲应自然延续已有故事线。从导入章节的结尾处接续——推进现有冲突、兑现已埋伏笔、引入从当前局势中有机产生的新变数。不要回顾已知信息。`;
 
     const workingModeEn = isSeries
       ? `## Working Mode
@@ -804,26 +1151,124 @@ ${trimmed}\n`;
       parsedSections.set(normalizedName, content.slice(start, end).trim());
     }
 
-    const extract = (name: string): string => {
-      const section = parsedSections.get(this.normalizeSectionName(name));
-      if (!section) {
-        throw new Error(`Architect output missing required section: ${name}`);
-      }
-      if (name !== "pending_hooks") {
-        return section;
-      }
-      return this.normalizePendingHooksSection(this.stripTrailingAssistantCoda(section));
-    };
+    // Phase 5 新 sections
+    const storyFrame = parsedSections.get("story_frame") ?? "";
+    const volumeMap = parsedSections.get("volume_map") ?? "";
+    const rhythmPrinciples = parsedSections.get("rhythm_principles") ?? "";
+    const rolesRaw = parsedSections.get("roles") ?? "";
+    // Legacy sections — 当 LLM 还按老 prompt 输出时兜底。
+    const legacyStoryBible = parsedSections.get("story_bible") ?? "";
+    const legacyVolumeOutline = parsedSections.get("volume_outline") ?? "";
+    const bookRules = parsedSections.get("book_rules");
+    const currentStateLegacy = parsedSections.get("current_state") ?? "";
+    const pendingHooksRaw = parsedSections.get("pending_hooks");
+
+    // 用老名字输出且没有 story_frame/volume_map 时,roles 可空(走 legacy shim fallback)。
+    const usingLegacyOutlineNames = !storyFrame && !volumeMap
+      && (legacyStoryBible.length > 0 || legacyVolumeOutline.length > 0);
+
+    const effectiveStoryFrame = storyFrame || legacyStoryBible;
+    const effectiveVolumeMap = volumeMap || legacyVolumeOutline;
+
+    const missing: string[] = [];
+    if (!effectiveStoryFrame) missing.push("story_frame");
+    if (!effectiveVolumeMap) missing.push("volume_map");
+    if (!rolesRaw.trim() && !usingLegacyOutlineNames) missing.push("roles");
+    if (!bookRules) missing.push("book_rules");
+    if (!pendingHooksRaw) missing.push("pending_hooks");
+    if (missing.length > 0) {
+      throw new Error(
+        `Architect output missing required section${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`,
+      );
+    }
+
+    const roles = this.parseRoles(rolesRaw);
+    const pendingHooks = this.normalizePendingHooksSection(
+      this.stripTrailingAssistantCoda(pendingHooksRaw!),
+    );
+
+    // Shim-facing 老字段:新 prompt 下用 buildStoryBibleShim 生成指针内容;
+    // 老 prompt 下直接用老内容。volumeOutline 也同理。
+    const storyBible = legacyStoryBible || effectiveStoryFrame;
+    const volumeOutline = legacyVolumeOutline || effectiveVolumeMap;
 
     return {
-      storyBible: extract("story_bible"),
-      volumeOutline: extract("volume_outline"),
-      bookRules: extract("book_rules"),
-      currentState: extract("current_state"),
-      pendingHooks: extract("pending_hooks"),
+      storyBible,
+      volumeOutline,
+      bookRules: bookRules!,
+      // current_state 在 Phase 5 下不是必需 section——writeFoundationFiles 会 seed
+      // 一个占位文件,consolidator 运行时按章追加。
+      currentState: currentStateLegacy,
+      pendingHooks,
+      storyFrame: effectiveStoryFrame,
+      volumeMap: effectiveVolumeMap,
+      rhythmPrinciples,
+      roles,
     };
   }
 
+  /** Parse ---ROLE---...---CONTENT--- 块。畸形块静默丢弃。 */
+  private parseRoles(raw: string): ReadonlyArray<ArchitectRole> {
+    if (!raw.trim()) return [];
+
+    const blocks = raw.split(/^---ROLE---$/m).map((chunk) => chunk.trim()).filter(Boolean);
+    const roles: ArchitectRole[] = [];
+
+    for (const block of blocks) {
+      const contentSplit = block.split(/^---CONTENT---$/m);
+      if (contentSplit.length < 2) continue;
+
+      const headerRaw = contentSplit[0]!.trim();
+      const content = contentSplit.slice(1).join("\n---CONTENT---\n").trim();
+
+      const tierMatch = headerRaw.match(/tier\s*[::]\s*(major|minor|主要|次要)/i);
+      const nameMatch = headerRaw.match(/name\s*[::]\s*(.+)/i);
+      if (!tierMatch || !nameMatch) continue;
+
+      const tierValue = tierMatch[1]!.toLowerCase();
+      const tier: "major" | "minor" = (tierValue === "major" || tierValue === "主要") ? "major" : "minor";
+      const name = nameMatch[1]!.trim();
+      if (!name || !content) continue;
+
+      roles.push({ tier, name, content });
+    }
+
+    return roles;
+  }
+
+  private buildStoryBibleShim(storyFrame: string, language: "zh" | "en"): string {
+    if (language === "en") {
+      return `# Story Bible (compat pointer — deprecated)\n\n> This file is kept for external readers only. The authoritative source is now:\n> - outline/story_frame.md (theme / tonal ground / core conflict / world rules / endgame)\n> - outline/volume_map.md (volume-level plot map)\n> - roles/ directory (one-file-per-character sheets)\n\n## Excerpt from story_frame\n\n${storyFrame.slice(0, 2000)}\n`;
+    }
+    return `# 故事圣经(兼容指针——已废弃)\n\n> 本文件仅为外部读取保留。权威来源已迁移至:\n> - outline/story_frame.md(主题 / 基调 / 核心冲突 / 世界铁律 / 终局)\n> - outline/volume_map.md(卷级分卷地图)\n> - roles/ 文件夹(一人一卡角色档案)\n\n## story_frame 摘录\n\n${storyFrame.slice(0, 2000)}\n`;
+  }
+
+  private buildCharacterMatrixShim(roles: ReadonlyArray<ArchitectRole>, language: "zh" | "en"): string {
+    const majorLines = roles.filter((role) => role.tier === "major")
+      .map((role) => `- roles/主要角色/${role.name}.md`);
+    const minorLines = roles.filter((role) => role.tier === "minor")
+      .map((role) => `- roles/次要角色/${role.name}.md`);
+
+    if (language === "en") {
+      return `# Character Matrix (compat pointer — deprecated)\n\n> This file is kept for external readers only. Authoritative source is now the roles/ directory (one-file-per-character).\n\n## Major characters\n\n${majorLines.join("\n") || "(none)"}\n\n## Minor characters\n\n${minorLines.join("\n") || "(none)"}\n`;
+    }
+    return `# 角色矩阵(兼容指针——已废弃)\n\n> 本文件仅为外部读取保留。权威来源已迁移至 roles/ 文件夹(一人一卡)。\n\n## 主要角色\n\n${majorLines.join("\n") || "(无)"}\n\n## 次要角色\n\n${minorLines.join("\n") || "(无)"}\n`;
+  }
+
+  private buildBookRulesShim(bookRulesBody: string, language: "zh" | "en"): string {
+    const trimmedBody = bookRulesBody.trim();
+    if (language === "en") {
+      const excerpt = trimmedBody
+        ? `\n\n## Narrative guidance excerpt\n\n${trimmedBody}\n`
+        : "";
+      return `# Book Rules (compat pointer — deprecated)\n\n> This file is kept for external readers only. The authoritative YAML frontmatter now lives at the top of outline/story_frame.md.${excerpt}`;
+    }
+    const excerpt = trimmedBody
+      ? `\n\n## 叙事指引摘录\n\n${trimmedBody}\n`
+      : "";
+    return `# 本书规则(兼容指针——已废弃)\n\n> 本文件仅为外部读取保留。权威 YAML frontmatter 已迁移至 outline/story_frame.md 顶部。${excerpt}`;
+  }
+
   private normalizeSectionName(name: string): string {
     return name
       .normalize("NFKC")

+ 5 - 4
packages/core/src/agents/chapter-analyzer.ts

@@ -3,6 +3,7 @@ import type { BookConfig } from "../models/book.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import type { ContextPackage, RuleStack } from "../models/input-governance.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
+import { readStoryFrame, readVolumeMap, readCharacterContext, readCurrentStateWithFallback } from "../utils/outline-paths.js";
 import { parseWriterOutput, type ParsedWriterOutput } from "./writer-parser.js";
 import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
 import {
@@ -45,14 +46,14 @@ export class ChapterAnalyzerAgent extends BaseAgent {
       subplotBoard, emotionalArcs, characterMatrix,
       storyBible, volumeOutline,
     ] = await Promise.all([
-      this.readFileOrDefault(join(bookDir, "story/current_state.md"), resolvedLanguage),
+      readCurrentStateWithFallback(bookDir),
       this.readFileOrDefault(join(bookDir, "story/particle_ledger.md"), resolvedLanguage),
       this.readFileOrDefault(join(bookDir, "story/pending_hooks.md"), resolvedLanguage),
       this.readFileOrDefault(join(bookDir, "story/subplot_board.md"), resolvedLanguage),
       this.readFileOrDefault(join(bookDir, "story/emotional_arcs.md"), resolvedLanguage),
-      this.readFileOrDefault(join(bookDir, "story/character_matrix.md"), resolvedLanguage),
-      this.readFileOrDefault(join(bookDir, "story/story_bible.md"), resolvedLanguage),
-      this.readFileOrDefault(join(bookDir, "story/volume_outline.md"), resolvedLanguage),
+      readCharacterContext(bookDir),
+      readStoryFrame(bookDir),
+      readVolumeMap(bookDir),
     ]);
     const parsedBookRules = await readBookRules(bookDir);
     const bookRulesBody = parsedBookRules?.body ?? "";

+ 2 - 2
packages/core/src/agents/consolidator.ts

@@ -1,6 +1,7 @@
 import { BaseAgent } from "./base.js";
 import { readFile, writeFile, mkdir } from "node:fs/promises";
 import { join } from "node:path";
+import { readVolumeMap } from "../utils/outline-paths.js";
 
 export interface ConsolidationResult {
   readonly volumeSummaries: string;
@@ -26,12 +27,11 @@ export class ConsolidatorAgent extends BaseAgent {
   async consolidate(bookDir: string): Promise<ConsolidationResult> {
     const storyDir = join(bookDir, "story");
     const summariesPath = join(storyDir, "chapter_summaries.md");
-    const outlinePath = join(storyDir, "volume_outline.md");
     const volumeSummariesPath = join(storyDir, "volume_summaries.md");
 
     const [summariesRaw, outlineRaw] = await Promise.all([
       readFile(summariesPath, "utf-8").catch(() => ""),
-      readFile(outlinePath, "utf-8").catch(() => ""),
+      readVolumeMap(bookDir),
     ]);
 
     if (!summariesRaw || !outlineRaw) {

+ 4 - 3
packages/core/src/agents/continuity.ts

@@ -6,6 +6,7 @@ import type { ContextPackage, RuleStack } from "../models/input-governance.js";
 import { readGenreProfile, readBookLanguage, readBookRules } from "./rules-reader.js";
 import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions.js";
 import { readFile, readdir } from "node:fs/promises";
+import { readCurrentStateWithFallback, readCharacterContext, readVolumeMap } from "../utils/outline-paths.js";
 import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
 import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
 import { join } from "node:path";
@@ -334,17 +335,17 @@ export class ContinuityAuditor extends BaseAgent {
   ): Promise<AuditResult> {
     const [diskCurrentState, diskLedger, diskHooks, styleGuideRaw, subplotBoard, emotionalArcs, characterMatrix, chapterSummaries, parentCanon, fanficCanon, volumeOutline] =
       await Promise.all([
-        this.readFileSafe(join(bookDir, "story/current_state.md")),
+        readCurrentStateWithFallback(bookDir),
         this.readFileSafe(join(bookDir, "story/particle_ledger.md")),
         this.readFileSafe(join(bookDir, "story/pending_hooks.md")),
         this.readFileSafe(join(bookDir, "story/style_guide.md")),
         this.readFileSafe(join(bookDir, "story/subplot_board.md")),
         this.readFileSafe(join(bookDir, "story/emotional_arcs.md")),
-        this.readFileSafe(join(bookDir, "story/character_matrix.md")),
+        readCharacterContext(bookDir),
         this.readFileSafe(join(bookDir, "story/chapter_summaries.md")),
         this.readFileSafe(join(bookDir, "story/parent_canon.md")),
         this.readFileSafe(join(bookDir, "story/fanfic_canon.md")),
-        this.readFileSafe(join(bookDir, "story/volume_outline.md")),
+        readVolumeMap(bookDir),
       ]);
     const currentState = options?.truthFileOverrides?.currentState ?? diskCurrentState;
     const ledger = options?.truthFileOverrides?.ledger ?? diskLedger;

+ 4 - 3
packages/core/src/agents/planner.ts

@@ -1,6 +1,7 @@
 import { readFile, writeFile, mkdir } from "node:fs/promises";
 import { join } from "node:path";
 import { BaseAgent } from "./base.js";
+import { readStoryFrame, readVolumeMap, readCurrentStateWithFallback } from "../utils/outline-paths.js";
 import type { BookConfig } from "../models/book.js";
 import { parseBookRules } from "../models/book-rules.js";
 import { ChapterIntentSchema, type ChapterConflict, type ChapterIntent } from "../models/input-governance.js";
@@ -58,11 +59,11 @@ export class PlannerAgent extends BaseAgent {
     ] = await Promise.all([
       this.readFileOrDefault(sourcePaths.authorIntent),
       this.readFileOrDefault(sourcePaths.currentFocus),
-      this.readFileOrDefault(sourcePaths.storyBible),
-      this.readFileOrDefault(sourcePaths.volumeOutline),
+      readStoryFrame(input.bookDir),
+      readVolumeMap(input.bookDir),
       this.readFileOrDefault(sourcePaths.chapterSummaries),
       this.readFileOrDefault(sourcePaths.bookRules),
-      this.readFileOrDefault(sourcePaths.currentState),
+      readCurrentStateWithFallback(input.bookDir),
     ]);
 
     const outlineNode = this.findOutlineNode(volumeOutline, input.chapterNumber);

+ 5 - 4
packages/core/src/agents/reviser.ts

@@ -5,6 +5,7 @@ import type { LengthSpec } from "../models/length-governance.js";
 import type { AuditIssue } from "./continuity.js";
 import type { ContextPackage, RuleStack } from "../models/input-governance.js";
 import { readGenreProfile, readBookLanguage, readBookRules } from "./rules-reader.js";
+import { readStoryFrame, readVolumeMap, readCharacterContext, readCurrentStateWithFallback } from "../utils/outline-paths.js";
 import { countChapterLength } from "../utils/length-metrics.js";
 import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
 import { filterSummaries } from "../utils/context-filter.js";
@@ -74,13 +75,13 @@ export class ReviserAgent extends BaseAgent {
     },
   ): Promise<ReviseOutput> {
     const [currentState, ledger, hooks, styleGuideRaw, volumeOutline, storyBible, characterMatrix, chapterSummaries, parentCanon, fanficCanon] = await Promise.all([
-      this.readFileSafe(join(bookDir, "story/current_state.md")),
+      readCurrentStateWithFallback(bookDir),
       this.readFileSafe(join(bookDir, "story/particle_ledger.md")),
       this.readFileSafe(join(bookDir, "story/pending_hooks.md")),
       this.readFileSafe(join(bookDir, "story/style_guide.md")),
-      this.readFileSafe(join(bookDir, "story/volume_outline.md")),
-      this.readFileSafe(join(bookDir, "story/story_bible.md")),
-      this.readFileSafe(join(bookDir, "story/character_matrix.md")),
+      readVolumeMap(bookDir),
+      readStoryFrame(bookDir),
+      readCharacterContext(bookDir),
       this.readFileSafe(join(bookDir, "story/chapter_summaries.md")),
       this.readFileSafe(join(bookDir, "story/parent_canon.md")),
       this.readFileSafe(join(bookDir, "story/fanfic_canon.md")),

+ 8 - 7
packages/core/src/agents/writer.ts

@@ -8,6 +8,7 @@ import { buildObserverSystemPrompt, buildObserverUserPrompt } from "./observer-p
 import { parseSettlerDeltaOutput } from "./settler-delta-parser.js";
 import { parseSettlementOutput } from "./settler-parser.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
+import { readStoryFrame, readVolumeMap, readCharacterContext, readCurrentStateWithFallback } from "../utils/outline-paths.js";
 import {
   detectCrossChapterRepetition,
   detectParagraphLengthDrift,
@@ -123,16 +124,16 @@ export class WriterAgent extends BaseAgent {
       chapterSummaries, subplotBoard, emotionalArcs, characterMatrix, styleProfileRaw,
       parentCanon, fanficCanonRaw,
     ] = await Promise.all([
-        this.readFileOrDefault(join(bookDir, "story/story_bible.md")),
-        this.readFileOrDefault(join(bookDir, "story/volume_outline.md")),
+        readStoryFrame(bookDir),
+        readVolumeMap(bookDir),
         this.readFileOrDefault(join(bookDir, "story/style_guide.md")),
-        this.readFileOrDefault(join(bookDir, "story/current_state.md")),
+        readCurrentStateWithFallback(bookDir),
         this.readFileOrDefault(join(bookDir, "story/particle_ledger.md")),
         this.readFileOrDefault(join(bookDir, "story/pending_hooks.md")),
         this.readFileOrDefault(join(bookDir, "story/chapter_summaries.md")),
         this.readFileOrDefault(join(bookDir, "story/subplot_board.md")),
         this.readFileOrDefault(join(bookDir, "story/emotional_arcs.md")),
-        this.readFileOrDefault(join(bookDir, "story/character_matrix.md")),
+        readCharacterContext(bookDir),
         this.readFileOrDefault(join(bookDir, "story/style_profile.json")),
         this.readFileOrDefault(join(bookDir, "story/parent_canon.md")),
         this.readFileOrDefault(join(bookDir, "story/fanfic_canon.md")),
@@ -419,14 +420,14 @@ export class WriterAgent extends BaseAgent {
       characterMatrix,
       volumeOutline,
     ] = await Promise.all([
-      this.readFileOrDefault(join(input.bookDir, "story/current_state.md")),
+      readCurrentStateWithFallback(input.bookDir),
       this.readFileOrDefault(join(input.bookDir, "story/particle_ledger.md")),
       this.readFileOrDefault(join(input.bookDir, "story/pending_hooks.md")),
       this.readFileOrDefault(join(input.bookDir, "story/chapter_summaries.md")),
       this.readFileOrDefault(join(input.bookDir, "story/subplot_board.md")),
       this.readFileOrDefault(join(input.bookDir, "story/emotional_arcs.md")),
-      this.readFileOrDefault(join(input.bookDir, "story/character_matrix.md")),
-      this.readFileOrDefault(join(input.bookDir, "story/volume_outline.md")),
+      readCharacterContext(input.bookDir),
+      readVolumeMap(input.bookDir),
     ]);
 
     const { profile: genreProfile } = await readGenreProfile(this.ctx.projectRoot, input.book.genre);

+ 19 - 2
packages/core/src/llm/provider.ts

@@ -93,8 +93,21 @@ export interface LLMClient {
   readonly _apiKey?: string;
   readonly defaults: {
     readonly temperature: number;
+    /**
+     * Per-call fallback: 当 agent 调 chat() 不传 options.maxTokens 时用这个值。
+     * 不是硬上限——per-call 显式传的值会覆盖它,不会被它限制。
+     */
     readonly maxTokens: number;
-    readonly maxTokensCap: number | null; // non-null only when user explicitly configured
+    /**
+     * Per-call 硬上限。null 表示不封顶;非 null 表示给 chat() 的 per-call
+     * maxTokens 加一个 Math.min(perCall, cap) 约束。
+     *
+     * 语义必须跟 defaults.maxTokens 严格分开:maxTokens 是 fallback,
+     * maxTokensCap 是 cap。旧实现把两者用同一个数推导,导致 agent per-call 16384
+     * 被 config.maxTokens=8192 误裁(见 tests/__tests__/provider.test.ts 的
+     * "per-call maxTokens is not capped by config.maxTokens" 回归测试)。
+     */
+    readonly maxTokensCap: number | null;
     readonly thinkingBudget: number;
     readonly extra: Record<string, unknown>;
   };
@@ -130,8 +143,12 @@ export interface ChatWithToolsResult {
 export function createLLMClient(config: LLMConfig): LLMClient {
   const defaults = {
     temperature: config.temperature ?? 0.7,
+    // fallback: agent 没传 per-call 时用这个
     maxTokens: config.maxTokens ?? 8192,
-    maxTokensCap: config.maxTokens ?? null, // only cap when user explicitly set maxTokens
+    // cap: 只在用户显式配 maxTokensCap 时生效;默认 null = 不封顶 per-call。
+    // **禁止**改成 `config.maxTokens ?? null` —— 那样会让 architect 的 per-call
+    // 16384 被用户 config.maxTokens=8192 自动裁剪,基础设定输出会被截断。
+    maxTokensCap: config.maxTokensCap ?? null,
     thinkingBudget: config.thinkingBudget ?? 0,
     extra: config.extra ?? {},
   };

+ 7 - 0
packages/core/src/models/project.ts

@@ -19,6 +19,13 @@ export const LLMConfigSchema = z.object({
   model: z.string().min(1),
   temperature: z.number().min(0).max(2).default(0.7),
   maxTokens: z.number().int().min(1).default(8192),
+  // per-call 硬上限。默认 undefined → createLLMClient 里变 null → 不封顶 per-call。
+  // 只有用户确实要限制单次调用最大输出时才显式设。注意:这个字段跟 maxTokens
+  // 语义不同:maxTokens 是"agent 没传 per-call 时的 fallback",maxTokensCap
+  // 是"给 agent per-call 加硬上限"。分开成两个字段是为了避免旧实现下
+  // config.maxTokens 同时被当 fallback 和 cap 的歧义(旧行为会把 architect
+  // per-call 16384 裁到 config.maxTokens=8192,导致基础设定输出被截断)。
+  maxTokensCap: z.number().int().min(1).optional(),
   thinkingBudget: z.number().int().min(0).default(0),
   extra: z.record(z.unknown()).optional(),
   headers: z.record(z.string()).optional(),

+ 170 - 5
packages/core/src/pipeline/runner.ts

@@ -35,6 +35,7 @@ import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state
 import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
 import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/promises";
 import { join } from "node:path";
+import { readStoryFrame, readVolumeMap, readCharacterContext, readCurrentStateWithFallback, isNewLayoutBook } from "../utils/outline-paths.js";
 import {
   parseStateDegradedReviewNote,
   resolveStateDegradedBaseStatus,
@@ -513,6 +514,170 @@ export class PipelineRunner {
     }
   }
 
+  /**
+   * Revise an existing book's foundation — 把已有书的架构稿重写(legacy 条目式升级
+   * 到段落式 / Phase 5 书按 feedback 再次调整细节),把架构稿相关文件备份到
+   * story/.backup-<phase4|phase5>-<timestamp>/。
+   *
+   * 关键约束:**只改架构稿文件**(outline/ + roles/ + 4 个 legacy shim),
+   * 不动任何运行时状态文件(current_state / pending_hooks / particle_ledger /
+   * subplot_board / emotional_arcs)—— 跟 context-transform 里给 LLM 的 upgrade
+   * hint 承诺"升级只改架构稿,不动已写的章节"保持一致。
+   *
+   * 两种来源:
+   *   - legacy 书(没有 outline/story_frame.md):读 story_bible.md 等 4 个
+   *     flat 文件作为原内容
+   *   - Phase 5 书(outline/story_frame.md 已存在):读 outline/ + roles/ 的
+   *     权威内容作为原内容——**不能读 flat 文件**,那些是 shim(只有指针 + 2000
+   *     字摘录)会丢信息
+   */
+  async reviseFoundation(bookId: string, feedback: string): Promise<void> {
+    const bookDir = this.state.bookDir(bookId);
+    const storyDir = join(bookDir, "story");
+    const isPhase5 = await isNewLayoutBook(bookDir);
+
+    // 1. 备份架构稿相关文件(不包含运行时状态文件,那些不会被动)
+    const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+    const backupTag = isPhase5 ? "phase5" : "phase4";
+    const backupDir = join(storyDir, `.backup-${backupTag}-${timestamp}`);
+    await mkdir(backupDir, { recursive: true });
+
+    // 备份 legacy flat 文件(两种书都有这几个——legacy 是权威,Phase 5 是 shim)
+    const flatFiles = ["story_bible.md", "volume_outline.md", "book_rules.md", "character_matrix.md"];
+    for (const fileName of flatFiles) {
+      try {
+        const content = await readFile(join(storyDir, fileName), "utf-8");
+        await writeFile(join(backupDir, fileName), content, "utf-8");
+      } catch {
+        /* 文件不存在就跳过 */
+      }
+    }
+
+    // Phase 5 书还要备份权威来源:outline/ 和 roles/ 两个目录
+    if (isPhase5) {
+      await this.copyDirShallow(join(storyDir, "outline"), join(backupDir, "outline"));
+      await this.copyDirRecursive(join(storyDir, "roles"), join(backupDir, "roles"));
+    }
+
+    // 2. 读原内容作为 reviseFrom 输入 —— 必须从权威源读
+    const book = await this.state.loadBookConfig(bookId);
+    let oldStoryBible: string, oldVolumeOutline: string, oldBookRules: string, oldCharacterMatrix: string;
+
+    if (isPhase5) {
+      // Phase 5 书的 story_bible.md / volume_outline.md / character_matrix.md
+      // 都是 shim(只有指针 + 摘录),信息不完整。从 outline-paths helper 读
+      // 权威文件:outline/story_frame.md / outline/volume_map.md / roles/**/*.md
+      [oldStoryBible, oldVolumeOutline, oldCharacterMatrix] = await Promise.all([
+        readStoryFrame(bookDir),
+        readVolumeMap(bookDir),
+        readCharacterContext(bookDir),
+      ]);
+      // book_rules.md 在 Phase 5 下虽然是 shim,但 YAML frontmatter 已经搬到
+      // story_frame.md 顶部了(writeFoundationFiles 做的合并),这里读 shim
+      // 文件 OK——里面的"叙事指引摘录"部分是唯一遗留信息。
+      oldBookRules = await readFile(join(storyDir, "book_rules.md"), "utf-8").catch(() => "");
+    } else {
+      // legacy 书:4 个 flat 文件就是权威
+      [oldStoryBible, oldVolumeOutline, oldBookRules, oldCharacterMatrix] = await Promise.all([
+        readFile(join(storyDir, "story_bible.md"), "utf-8").catch(() => ""),
+        readFile(join(storyDir, "volume_outline.md"), "utf-8").catch(() => ""),
+        readFile(join(storyDir, "book_rules.md"), "utf-8").catch(() => ""),
+        readFile(join(storyDir, "character_matrix.md"), "utf-8").catch(() => ""),
+      ]);
+    }
+
+    // 3. 架构师按 reviseFrom + feedback 重写
+    const architect = new ArchitectAgent(this.agentCtxFor("architect", bookId));
+    const foundation = await architect.generateFoundation(book, undefined, undefined, {
+      reviseFrom: {
+        storyBible: oldStoryBible,
+        volumeOutline: oldVolumeOutline,
+        bookRules: oldBookRules,
+        characterMatrix: oldCharacterMatrix,
+        userFeedback: feedback,
+      },
+    });
+
+    // 4. 可选 foundation-reviewer 审核——挂了只 warn 不阻断
+    const reviewer = new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", bookId));
+    const resolvedLanguage = (book.language ?? "zh") === "en" ? "en" as const : "zh" as const;
+    try {
+      const review = await reviewer.review({
+        foundation,
+        mode: "original",
+        language: resolvedLanguage,
+      } as Parameters<FoundationReviewerAgent["review"]>[0]);
+      if (!review.passed) {
+        this.config.logger?.warn?.(
+          `[reviseFoundation] 审核未通过,仍接受转换结果。反馈:${review.overallFeedback ?? ""}`,
+        );
+      }
+    } catch (error) {
+      this.config.logger?.warn?.(
+        `[reviseFoundation] 审核调用失败,跳过:${error instanceof Error ? error.message : String(error)}`,
+      );
+    }
+
+    // 5. 目录补全 → 写新文件(mode="revise" 不动运行时状态 + 清空旧 role 文件)
+    const outlineDir = join(storyDir, "outline");
+    await mkdir(outlineDir, { recursive: true });
+    await mkdir(join(storyDir, "roles", "主要角色"), { recursive: true });
+    await mkdir(join(storyDir, "roles", "次要角色"), { recursive: true });
+
+    const { profile: gp } = await this.loadGenreProfile(book.genre);
+    await architect.writeFoundationFiles(
+      bookDir,
+      foundation,
+      gp.numericalSystem,
+      book.language ?? gp.language,
+      "revise",
+    );
+  }
+
+  /** Shallow copy (non-recursive) — used to back up outline/ which has no subdirs. */
+  private async copyDirShallow(src: string, dest: string): Promise<void> {
+    try {
+      await mkdir(dest, { recursive: true });
+      const entries = await readdir(src);
+      await Promise.all(
+        entries.map(async (entry) => {
+          try {
+            const content = await readFile(join(src, entry), "utf-8");
+            await writeFile(join(dest, entry), content, "utf-8");
+          } catch {
+            /* skip files we can't read */
+          }
+        }),
+      );
+    } catch {
+      /* src doesn't exist → nothing to back up */
+    }
+  }
+
+  /** Recursive copy — used to back up roles/ which has 主要角色/ + 次要角色/. */
+  private async copyDirRecursive(src: string, dest: string): Promise<void> {
+    try {
+      await mkdir(dest, { recursive: true });
+      const entries = await readdir(src, { withFileTypes: true });
+      for (const entry of entries) {
+        const srcPath = join(src, entry.name);
+        const destPath = join(dest, entry.name);
+        if (entry.isDirectory()) {
+          await this.copyDirRecursive(srcPath, destPath);
+        } else if (entry.isFile()) {
+          try {
+            const content = await readFile(srcPath, "utf-8");
+            await writeFile(destPath, content, "utf-8");
+          } catch {
+            /* skip */
+          }
+        }
+      }
+    } catch {
+      /* src doesn't exist */
+    }
+  }
+
   /** Import external source material and generate fanfic_canon.md */
   async importFanficCanon(
     bookId: string,
@@ -1119,8 +1284,8 @@ export class PipelineRunner {
         readSafe(join(storyDir, "current_state.md")),
         readSafe(join(storyDir, "particle_ledger.md")),
         readSafe(join(storyDir, "pending_hooks.md")),
-        readSafe(join(storyDir, "story_bible.md")),
-        readSafe(join(storyDir, "volume_outline.md")),
+        readStoryFrame(bookDir, "(文件不存在)"),
+        readVolumeMap(bookDir, "(文件不存在)"),
         readSafe(join(storyDir, "book_rules.md")),
       ]);
 
@@ -1847,14 +2012,14 @@ export class PipelineRunner {
 
     const [storyBible, currentState, ledger, hooks, summaries, subplots, emotions, matrix] =
       await Promise.all([
-        readSafe(join(parentDir, "story/story_bible.md")),
-        readSafe(join(parentDir, "story/current_state.md")),
+        readStoryFrame(parentDir, "(无)"),
+        readCurrentStateWithFallback(parentDir, "(无)"),
         readSafe(join(parentDir, "story/particle_ledger.md")),
         readSafe(join(parentDir, "story/pending_hooks.md")),
         readSafe(join(parentDir, "story/chapter_summaries.md")),
         readSafe(join(parentDir, "story/subplot_board.md")),
         readSafe(join(parentDir, "story/emotional_arcs.md")),
-        readSafe(join(parentDir, "story/character_matrix.md")),
+        readCharacterContext(parentDir, "(无)"),
       ]);
 
     const response = await chatCompletion(this.config.client, this.config.model, [

+ 6 - 0
packages/core/src/state/manager.ts

@@ -34,9 +34,15 @@ export class StateManager {
   ): Promise<void> {
     const storyDir = join(bookDir, "story");
     const runtimeDir = join(storyDir, "runtime");
+    const outlineDir = join(storyDir, "outline");
+    const rolesMajorDir = join(storyDir, "roles", "主要角色");
+    const rolesMinorDir = join(storyDir, "roles", "次要角色");
 
     await mkdir(storyDir, { recursive: true });
     await mkdir(runtimeDir, { recursive: true });
+    await mkdir(outlineDir, { recursive: true });
+    await mkdir(rolesMajorDir, { recursive: true });
+    await mkdir(rolesMinorDir, { recursive: true });
 
     await this.writeIfMissing(
       join(storyDir, "author_intent.md"),

+ 2 - 1
packages/core/src/utils/memory-retrieval.ts

@@ -1,5 +1,6 @@
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
+import { readCurrentStateWithFallback } from "./outline-paths.js";
 import {
   ChapterSummariesStateSchema,
   CurrentStateStateSchema,
@@ -71,7 +72,7 @@ export async function retrieveMemorySelection(params: {
     structuredHooks,
     structuredSummaries,
   ] = await Promise.all([
-    readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
+    readCurrentStateWithFallback(params.bookDir),
     readFile(join(storyDir, "volume_summaries.md"), "utf-8").catch(() => ""),
     readStructuredState(join(stateDir, "current_state.json"), CurrentStateStateSchema),
     readStructuredState(join(stateDir, "hooks.json"), HooksStateSchema),

+ 275 - 0
packages/core/src/utils/outline-paths.ts

@@ -0,0 +1,275 @@
+/**
+ * Phase 5 (v13) path resolution — prefer the new prose outline files, fall
+ * back to legacy paths so older books keep working during transition.
+ *
+ * Maps:
+ *   story/outline/story_frame.md  →  preferred replacement for story_bible.md
+ *   story/outline/volume_map.md   →  preferred replacement for volume_outline.md
+ *   story/roles/主要角色/*.md +
+ *   story/roles/次要角色/*.md    →  preferred replacement for character_matrix.md
+ *
+ * All helpers accept a bookDir (path to a book root, containing `story/`)
+ * and return a string — either the new-file content when it exists, or the
+ * legacy file content, or an empty default placeholder.
+ */
+
+import { readFile, readdir, access } from "node:fs/promises";
+import { join } from "node:path";
+
+/**
+ * Detect whether a book uses the Phase 5 new layout (outline/story_frame.md
+ * exists on disk). If yes, story_bible.md / book_rules.md are compat shims.
+ * If no, those files ARE the authoritative source.
+ */
+export async function isNewLayoutBook(bookDir: string): Promise<boolean> {
+  try {
+    await access(join(bookDir, "story", "outline", "story_frame.md"));
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+async function readOr(path: string, fallback: string): Promise<string> {
+  try {
+    return await readFile(path, "utf-8");
+  } catch {
+    return fallback;
+  }
+}
+
+/** Read story_frame.md, falling back to legacy story_bible.md. */
+export async function readStoryFrame(
+  bookDir: string,
+  fallbackPlaceholder: string = "",
+): Promise<string> {
+  const newPath = join(bookDir, "story", "outline", "story_frame.md");
+  const legacyPath = join(bookDir, "story", "story_bible.md");
+
+  const newContent = await readOr(newPath, "");
+  if (newContent.trim()) return newContent;
+
+  return readOr(legacyPath, fallbackPlaceholder);
+}
+
+/** Read volume_map.md, falling back to legacy volume_outline.md. */
+export async function readVolumeMap(
+  bookDir: string,
+  fallbackPlaceholder: string = "",
+): Promise<string> {
+  const newPath = join(bookDir, "story", "outline", "volume_map.md");
+  const legacyPath = join(bookDir, "story", "volume_outline.md");
+
+  const newContent = await readOr(newPath, "");
+  if (newContent.trim()) return newContent;
+
+  return readOr(legacyPath, fallbackPlaceholder);
+}
+
+/** Read the rhythm principles file (zh or en variant). */
+export async function readRhythmPrinciples(bookDir: string): Promise<string> {
+  const zhPath = join(bookDir, "story", "outline", "节奏原则.md");
+  const enPath = join(bookDir, "story", "outline", "rhythm_principles.md");
+
+  const zh = await readOr(zhPath, "");
+  if (zh.trim()) return zh;
+  return readOr(enPath, "");
+}
+
+export interface RoleCard {
+  readonly tier: "major" | "minor";
+  readonly name: string;
+  readonly content: string;
+}
+
+/**
+ * Read the roles/ directory. Returns [] when no roles are present (e.g. old
+ * books still on character_matrix.md).
+ */
+export async function readRoleCards(bookDir: string): Promise<ReadonlyArray<RoleCard>> {
+  const rolesRoot = join(bookDir, "story", "roles");
+  const majorDirZh = join(rolesRoot, "主要角色");
+  const minorDirZh = join(rolesRoot, "次要角色");
+  const majorDirEn = join(rolesRoot, "major");
+  const minorDirEn = join(rolesRoot, "minor");
+
+  const cards: RoleCard[] = [];
+  await Promise.all([
+    collectRoleDir(majorDirZh, "major", cards),
+    collectRoleDir(minorDirZh, "minor", cards),
+    collectRoleDir(majorDirEn, "major", cards),
+    collectRoleDir(minorDirEn, "minor", cards),
+  ]);
+  return cards;
+}
+
+async function collectRoleDir(
+  dir: string,
+  tier: "major" | "minor",
+  out: RoleCard[],
+): Promise<void> {
+  let entries: string[];
+  try {
+    entries = await readdir(dir);
+  } catch {
+    return;
+  }
+  const reads = entries
+    .filter((entry) => entry.endsWith(".md"))
+    .map(async (entry) => {
+      const content = await readOr(join(dir, entry), "");
+      if (!content.trim()) return;
+      out.push({
+        tier,
+        name: entry.replace(/\.md$/, ""),
+        content,
+      });
+    });
+  await Promise.all(reads);
+}
+
+/**
+ * Render role cards in a format compatible with downstream consumers that
+ * previously expected character_matrix.md prose. When no role cards exist,
+ * returns the legacy character_matrix.md content or the placeholder.
+ */
+export async function readCharacterContext(
+  bookDir: string,
+  fallbackPlaceholder: string = "",
+): Promise<string> {
+  const cards = await readRoleCards(bookDir);
+  if (cards.length > 0) {
+    const groups: Record<"major" | "minor", RoleCard[]> = { major: [], minor: [] };
+    for (const card of cards) groups[card.tier].push(card);
+
+    const render = (tierCards: RoleCard[], heading: string): string => {
+      if (tierCards.length === 0) return "";
+      const sections = tierCards.map((card) => `### ${card.name}\n\n${card.content.trim()}`);
+      return `## ${heading}\n\n${sections.join("\n\n")}`;
+    };
+
+    const blocks = [
+      render(groups.major, "主要角色 / Major characters"),
+      render(groups.minor, "次要角色 / Minor characters"),
+    ].filter(Boolean);
+
+    return blocks.join("\n\n");
+  }
+
+  // Fallback: legacy character_matrix.md (may itself be a shim pointer).
+  const legacyPath = join(bookDir, "story", "character_matrix.md");
+  return readOr(legacyPath, fallbackPlaceholder);
+}
+
+// ---------------------------------------------------------------------------
+// Phase 5 consolidation: current_state.md initial fallback
+//
+// After architect consolidation (7→5 sections), current_state.md is seeded
+// with a tiny placeholder at book creation. Real content only arrives once
+// the consolidator has appended output from chapter 1 onward. Readers that
+// previously relied on architect-provided initial state (writer phase-1
+// creative prompt, continuity, chapter-analyzer, reviser, composer) should
+// substitute a derived initial-state block when the seed placeholder is all
+// that's on disk — otherwise the "## 当前状态卡" block in prompts degenerates
+// into a meta note about runtime append behaviour.
+// ---------------------------------------------------------------------------
+
+/**
+ * Marker substring emitted by architect.writeFoundationFiles when seeding
+ * current_state.md. Its presence is how readers detect "nothing real yet".
+ */
+const CURRENT_STATE_SEED_MARKERS = [
+  "建书时占位",
+  "Seeded at book creation",
+];
+
+export function isCurrentStateSeedPlaceholder(raw: string): boolean {
+  const trimmed = raw.trim();
+  if (!trimmed) return true;
+  // Heuristic: short file AND contains one of the seed markers.
+  if (trimmed.length > 600) return false;
+  return CURRENT_STATE_SEED_MARKERS.some((marker) => trimmed.includes(marker));
+}
+
+function extractCurrentStateFromRole(content: string): string | null {
+  // Accept both zh (`## 当前现状`) and en (`## Current_State` / `## Current State`).
+  const pattern = /^##\s*(?:当前现状|Current[_\s]?State)[^\n]*$/im;
+  const match = content.match(pattern);
+  if (!match || match.index === undefined) return null;
+  const after = content.slice(match.index + match[0].length);
+  // Cut at next `## ` heading (same or higher level).
+  const nextHeading = after.search(/^##\s/m);
+  const raw = nextHeading >= 0 ? after.slice(0, nextHeading) : after;
+  const text = raw.trim();
+  return text.length > 0 ? text : null;
+}
+
+function extractSeedHooksFromPendingHooks(raw: string): string[] {
+  if (!raw.trim()) return [];
+  const lines = raw.split("\n").map((line) => line.trim()).filter(Boolean);
+  const seedRows: string[] = [];
+  for (const line of lines) {
+    if (!line.startsWith("|")) continue;
+    if (/^\|\s*-/.test(line)) continue;
+    const cells = line.split("|").slice(1, -1).map((cell) => cell.trim());
+    if (cells.length < 2) continue;
+    if (cells[0]?.toLowerCase() === "hook_id" || cells[0] === "hookId") continue;
+    const startCh = Number.parseInt(cells[1] ?? "", 10);
+    if (!Number.isFinite(startCh) || startCh !== 0) continue;
+    // cells[2] type, cells[5] expected payoff, last cell notes
+    const notes = cells[cells.length - 1] ?? "";
+    const summary = [cells[0], cells[2], notes].filter(Boolean).join(" · ");
+    if (summary) seedRows.push(summary);
+  }
+  return seedRows;
+}
+
+/**
+ * Read current_state.md; when the file is only a seed placeholder (chapter 0,
+ * before consolidator has appended anything), derive an initial-state block
+ * from roles/*.Current_State + pending_hooks startChapter=0 rows so callers
+ * still have substantive content to feed into writer / analyzer prompts.
+ */
+export async function readCurrentStateWithFallback(
+  bookDir: string,
+  fallbackPlaceholder: string = "",
+): Promise<string> {
+  const storyDir = join(bookDir, "story");
+  const currentStatePath = join(storyDir, "current_state.md");
+  const raw = await readOr(currentStatePath, "");
+
+  if (!isCurrentStateSeedPlaceholder(raw)) {
+    return raw;
+  }
+
+  const [cards, pendingHooks] = await Promise.all([
+    readRoleCards(bookDir),
+    readOr(join(storyDir, "pending_hooks.md"), ""),
+  ]);
+
+  const roleLines = cards
+    .map((card) => {
+      const state = extractCurrentStateFromRole(card.content);
+      if (!state) return null;
+      const tierLabel = card.tier === "major" ? "主要" : "次要";
+      return `- ${card.name}(${tierLabel}):${state.replace(/\s+/g, " ")}`;
+    })
+    .filter((line): line is string => line !== null);
+
+  const hookLines = extractSeedHooksFromPendingHooks(pendingHooks);
+
+  if (roleLines.length === 0 && hookLines.length === 0) {
+    return raw.trim() ? raw : fallbackPlaceholder;
+  }
+
+  const parts: string[] = ["# 初始状态(第 0 章,由 roles + 种子伏笔派生)"];
+  if (roleLines.length > 0) {
+    parts.push("\n## 角色初始位置 / 处境");
+    parts.push(...roleLines);
+  }
+  if (hookLines.length > 0) {
+    parts.push("\n## 种子伏笔(startChapter = 0)");
+    parts.push(...hookLines.map((line) => `- ${line}`));
+  }
+  return parts.join("\n");
+}

+ 0 - 199
packages/studio/src/components/chat/BookFormCard.tsx

@@ -1,199 +0,0 @@
-import type { Theme } from "../../hooks/use-theme";
-import { cn } from "../../lib/utils";
-import { Tool, ToolHeader, ToolContent } from "../ai-elements/tool";
-import { Loader2 } from "lucide-react";
-
-export interface BookFormArgs {
-  title?: string;
-  genre?: string;
-  platform?: string;
-  targetChapters?: number;
-  chapterWordCount?: number;
-  language?: string;
-  brief?: string;
-}
-
-export interface BookFormCardProps {
-  readonly args: BookFormArgs;
-  readonly onArgsChange: (args: BookFormArgs) => void;
-  readonly onConfirm: () => void;
-  readonly confirming: boolean;
-  readonly theme: Theme;
-}
-
-const PLATFORM_OPTIONS = [
-  { label: "番茄小说", value: "tomato" },
-  { label: "起点中文网", value: "qidian" },
-  { label: "飞卢", value: "feilu" },
-  { label: "其他", value: "other" },
-] as const;
-
-const LANGUAGE_OPTIONS = [
-  { label: "中文", value: "zh" },
-  { label: "English", value: "en" },
-] as const;
-
-const labelClass = "text-[10px] uppercase tracking-[0.18em] text-muted-foreground font-semibold";
-
-const inputClass = cn(
-  "w-full rounded-lg border border-border/60 bg-background/80 px-3 py-1.5 text-sm",
-  "outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/10 transition-all",
-  "placeholder:text-muted-foreground/40",
-);
-
-function RadioGroup({
-  options,
-  value,
-  onChange,
-  disabled,
-}: {
-  readonly options: ReadonlyArray<{ readonly label: string; readonly value: string }>;
-  readonly value: string | undefined;
-  readonly onChange: (v: string) => void;
-  readonly disabled?: boolean;
-}) {
-  return (
-    <div className="flex flex-wrap gap-2">
-      {options.map((opt) => (
-        <button
-          key={opt.value}
-          type="button"
-          onClick={() => onChange(opt.value)}
-          disabled={disabled}
-          className={cn(
-            "px-3 py-1 rounded-lg text-xs font-medium border transition-all",
-            value === opt.value
-              ? "border-primary bg-primary/10 text-primary"
-              : "border-border/60 bg-background/60 text-muted-foreground hover:border-primary/30",
-            disabled && "opacity-50 cursor-not-allowed",
-          )}
-        >
-          {opt.label}
-        </button>
-      ))}
-    </div>
-  );
-}
-
-export function BookFormCard({ args, onArgsChange, onConfirm, confirming }: BookFormCardProps) {
-  const disabled = confirming;
-  const update = <K extends keyof BookFormArgs>(key: K, value: BookFormArgs[K]) => {
-    if (disabled) return;
-    onArgsChange({ ...args, [key]: value });
-  };
-
-  const disabledInput = disabled ? "opacity-60 cursor-not-allowed" : "";
-  const toolState = confirming ? "input-available" : "approval-requested";
-
-  return (
-    <Tool defaultOpen>
-      <ToolHeader
-        title="创建新书"
-        type="tool-invocation"
-        state={toolState}
-      />
-      <ToolContent>
-        <div className={cn("space-y-4", disabled && "pointer-events-none opacity-80")}>
-          {/* 书名 */}
-          <div className="space-y-1.5">
-            <label className={labelClass}>书名</label>
-            <input
-              type="text"
-              value={args.title ?? ""}
-              onChange={(e) => update("title", e.target.value)}
-              placeholder="输入书名"
-              disabled={disabled}
-              className={cn(inputClass, disabledInput)}
-            />
-          </div>
-
-          {/* 题材 */}
-          <div className="space-y-1.5">
-            <label className={labelClass}>题材</label>
-            <input
-              type="text"
-              value={args.genre ?? ""}
-              onChange={(e) => update("genre", e.target.value)}
-              placeholder="如 xuanhuan、urban、romance"
-              disabled={disabled}
-              className={cn(inputClass, disabledInput)}
-            />
-          </div>
-
-          {/* 目标平台 */}
-          <div className="space-y-1.5">
-            <label className={labelClass}>目标平台</label>
-            <RadioGroup
-              options={PLATFORM_OPTIONS}
-              value={args.platform}
-              onChange={(v) => update("platform", v)}
-              disabled={disabled}
-            />
-          </div>
-
-          {/* 目标章数 + 每章字数 */}
-          <div className="grid grid-cols-2 gap-3">
-            <div className="space-y-1.5">
-              <label className={labelClass}>目标章数</label>
-              <input
-                type="number"
-                value={args.targetChapters ?? ""}
-                onChange={(e) => update("targetChapters", e.target.value ? Number(e.target.value) : undefined)}
-                placeholder="如 200"
-                disabled={disabled}
-                className={cn(inputClass, disabledInput)}
-              />
-            </div>
-            <div className="space-y-1.5">
-              <label className={labelClass}>每章字数</label>
-              <input
-                type="number"
-                value={args.chapterWordCount ?? ""}
-                onChange={(e) => update("chapterWordCount", e.target.value ? Number(e.target.value) : undefined)}
-                placeholder="如 2000"
-                disabled={disabled}
-                className={cn(inputClass, disabledInput)}
-              />
-            </div>
-          </div>
-
-          {/* 写作语言 */}
-          <div className="space-y-1.5">
-            <label className={labelClass}>写作语言</label>
-            <RadioGroup
-              options={LANGUAGE_OPTIONS}
-              value={args.language}
-              onChange={(v) => update("language", v)}
-              disabled={disabled}
-            />
-          </div>
-
-          {/* 创意简述 */}
-          <div className="space-y-1.5">
-            <label className={labelClass}>创意简述</label>
-            <div className={cn(
-              "rounded-lg border border-border/60 bg-background/80 px-3 py-2 text-sm leading-7 whitespace-pre-wrap",
-              disabled ? "opacity-60" : "",
-            )}>
-              {args.brief || <span className="text-muted-foreground/40">AI 会根据你的描述自动生成</span>}
-            </div>
-          </div>
-        </div>
-
-        {/* 确认按钮 */}
-        <div className="flex items-center justify-between pt-4 border-t border-border/40 mt-4">
-          <p className="text-xs text-muted-foreground">确认后将由 Architect 生成完整 foundation</p>
-          <button
-            type="button"
-            onClick={onConfirm}
-            disabled={confirming}
-            className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
-          >
-            {confirming && <Loader2 size={14} className="animate-spin" />}
-            {confirming ? "创建中…" : "开始写这本书"}
-          </button>
-        </div>
-      </ToolContent>
-    </Tool>
-  );
-}

+ 1 - 37
packages/studio/src/components/chat/ChatMessage.tsx

@@ -1,51 +1,25 @@
 import type { Theme } from "../../hooks/use-theme";
-import { cn } from "../../lib/utils";
 import {
   Message,
   MessageContent,
   MessageResponse,
 } from "../ai-elements/message";
-import { BookFormCard } from "./BookFormCard";
-import type { BookFormArgs } from "./BookFormCard";
-import {
-  CheckCircle2,
-  XCircle,
-  Loader2,
-} from "lucide-react";
-
-export interface ToolCall {
-  readonly name: string;
-  readonly arguments: Record<string, unknown>;
-}
+import { XCircle } from "lucide-react";
 
 export interface ChatMessageProps {
   readonly role: "user" | "assistant";
   readonly content: string;
   readonly timestamp: number;
   readonly theme: Theme;
-  readonly toolCall?: ToolCall;
-  readonly onArgsChange?: (args: Record<string, unknown>) => void;
-  readonly onConfirm?: () => void;
-  readonly confirming?: boolean;
 }
 
 export function ChatMessage({
   role,
   content,
-  timestamp,
-  theme,
-  toolCall,
-  onArgsChange,
-  onConfirm,
-  confirming,
 }: ChatMessageProps) {
   const isUser = role === "user";
-  const isStatus = content.startsWith("\u22EF");
-  const isSuccess = content.startsWith("\u2713");
   const isError = content.startsWith("\u2717");
 
-  const hasBookForm = toolCall?.name === "create_book" && onArgsChange && onConfirm;
-
   return (
     <Message from={role}>
       <MessageContent>
@@ -60,16 +34,6 @@ export function ChatMessage({
           <MessageResponse>{content}</MessageResponse>
         )}
       </MessageContent>
-
-      {hasBookForm && (
-        <BookFormCard
-          args={toolCall.arguments as BookFormArgs}
-          onArgsChange={(a) => onArgsChange(a as Record<string, unknown>)}
-          onConfirm={onConfirm}
-          confirming={confirming ?? false}
-          theme={theme}
-        />
-      )}
     </Message>
   );
 }

+ 25 - 7
packages/studio/src/components/sidebar/SummarySection.tsx

@@ -1,9 +1,25 @@
 import { useEffect } from "react";
+import { Streamdown } from "streamdown";
+import { cjk } from "@streamdown/cjk";
+import { code } from "@streamdown/code";
+import { math } from "@streamdown/math";
+import { mermaid } from "@streamdown/mermaid";
 import { useChatStore } from "../../store/chat";
 import type { BookSummary } from "../../store/chat";
 import { fetchJson } from "../../hooks/use-api";
 import { SidebarCard } from "./SidebarCard";
 
+const streamdownPlugins = { cjk, code, math, mermaid };
+
+const SIDEBAR_MD_CLASS =
+  "text-xs text-muted-foreground leading-relaxed " +
+  "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 " +
+  "[&>p+p]:mt-1.5 [&_strong]:text-foreground [&_strong]:font-medium " +
+  "[&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-0.5 " +
+  "[&_h1]:hidden [&_h2]:text-xs [&_h2]:font-medium [&_h2]:text-foreground [&_h2]:mt-1.5 [&_h2]:mb-0.5 " +
+  "[&_h3]:text-xs [&_h3]:font-medium [&_h3]:text-foreground [&_h3]:mt-1.5 [&_h3]:mb-0.5 " +
+  "[&_code]:text-[11px] [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:bg-secondary/60";
+
 function parseStoryBible(content: string): BookSummary {
   const sections = content.split(/^##\s+/m);
   let world = "";
@@ -47,22 +63,24 @@ export function SummarySection({ bookId }: SummarySectionProps) {
     <>
       {summary.world && (
         <SidebarCard title="世界观">
-          <p className="text-xs text-muted-foreground leading-relaxed line-clamp-4">
+          <Streamdown className={SIDEBAR_MD_CLASS} plugins={streamdownPlugins}>
             {summary.world}
-          </p>
+          </Streamdown>
         </SidebarCard>
       )}
       {(summary.protagonist || summary.cast) && (
         <SidebarCard title="角色">
           {summary.protagonist && (
-            <p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
+            <Streamdown className={SIDEBAR_MD_CLASS} plugins={streamdownPlugins}>
               {summary.protagonist}
-            </p>
+            </Streamdown>
           )}
           {summary.cast && (
-            <p className="text-xs text-muted-foreground leading-relaxed line-clamp-3 mt-2">
-              {summary.cast}
-            </p>
+            <div className={summary.protagonist ? "mt-2" : undefined}>
+              <Streamdown className={SIDEBAR_MD_CLASS} plugins={streamdownPlugins}>
+                {summary.cast}
+              </Streamdown>
+            </div>
           )}
         </SidebarCard>
       )}

+ 7 - 3
packages/studio/src/hooks/use-hash-route.ts

@@ -58,14 +58,18 @@ export function useHashRoute() {
   }, []);
 
   const setRoute = useCallback((newRoute: HashRoute) => {
+    // 先同步 React state:无论目标页面是否写 URL,保证页面立刻切换。
+    // 之前只在非 hash 页面才 setRouteState,hash 页面完全靠 hashchange 事件回调触发。
+    // 但当 URL 没有实际变化时(比如从 services → logs → services,中间的 logs
+    // 不写 URL,URL 一直停在 #/services),再次赋值同一个 hash 不会触发 hashchange,
+    // React state 就永远停留在 logs,表现为"点不动"。
+    setRouteState(newRoute);
     if (HASH_PAGES.has(newRoute.page)) {
       const hash = routeToHash(newRoute);
-      if (hash) {
+      if (hash && window.location.hash !== hash) {
         window.location.hash = hash;
-        return;
       }
     }
-    setRouteState(newRoute);
   }, []);
 
   const nav = {

+ 2 - 110
packages/studio/src/pages/ChatPage.tsx

@@ -2,7 +2,6 @@ import { useRef, useEffect, useMemo, useState } from "react";
 import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
 import type { SSEMessage } from "../hooks/use-sse";
-import { useApi } from "../hooks/use-api";
 import { chatSelectors, useChatStore } from "../store/chat";
 import { useServiceStore } from "../store/service";
 import {
@@ -32,7 +31,6 @@ import {
   MessageContent,
 } from "../components/ai-elements/message";
 import {
-  clearBookCreateSessionId,
   filterModelGroups,
   getBookCreateSessionId,
   setBookCreateSessionId,
@@ -58,22 +56,15 @@ export interface ChatPageProps {
 
 export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPageProps) {
   // -- Store selectors --
-  const activeSession = useChatStore(chatSelectors.activeSession);
   const messages = useChatStore(chatSelectors.activeMessages);
   const activeSessionId = useChatStore((s) => s.activeSessionId);
   const input = useChatStore((s) => s.input);
   const loading = useChatStore(chatSelectors.isActiveSessionStreaming);
-  const pendingBookArgs = activeSession?.pendingBookArgs ?? null;
-  const bookCreating = useChatStore((s) => s.bookCreating);
-  const createProgress = useChatStore((s) => s.createProgress);
   const selectedModel = useChatStore((s) => s.selectedModel);
   const selectedService = useChatStore((s) => s.selectedService);
   // -- Store actions --
   const setInput = useChatStore((s) => s.setInput);
   const sendMessage = useChatStore((s) => s.sendMessage);
-  const setPendingBookArgs = useChatStore((s) => s.setPendingBookArgs);
-  const handleCreateBook = useChatStore((s) => s.handleCreateBook);
-  const setCreateProgress = useChatStore((s) => s.setCreateProgress);
   const setSelectedModel = useChatStore((s) => s.setSelectedModel);
   const loadSessionList = useChatStore((s) => s.loadSessionList);
   const createSession = useChatStore((s) => s.createSession);
@@ -142,29 +133,12 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
     el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
   }, [input]);
 
-  // Auto-scroll on new messages or progress updates
+  // Auto-scroll on new messages
   useEffect(() => {
     if (scrollRef.current) {
       scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
     }
-  }, [messages, createProgress]);
-
-  // Listen for pipeline log events during book creation
-  useEffect(() => {
-    if (!bookCreating) {
-      setCreateProgress("");
-      return;
-    }
-    const es = new EventSource("/api/v1/events");
-    es.addEventListener("log", (e: MessageEvent) => {
-      try {
-        const data = e.data ? JSON.parse(e.data) : null;
-        const msg = data?.message as string | undefined;
-        if (msg) setCreateProgress(msg);
-      } catch { /* ignore */ }
-    });
-    return () => { es.close(); };
-  }, [bookCreating, setCreateProgress]);
+  }, [messages]);
 
   // Entering a book loads its latest session; book-create mode persists its orphan session in localStorage.
   useEffect(() => {
@@ -221,15 +195,6 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
     void sendMessage(activeSessionId, text, activeBookId);
   };
 
-  const onCreateBook = async () => {
-    if (!activeSessionId) return;
-    const newBookId = await handleCreateBook(activeSessionId, activeBookId);
-    if (newBookId) {
-      clearBookCreateSessionId();
-      nav.toBook(newBookId);
-    }
-  };
-
   const handleQuickAction = (command: string) => {
     if (!activeSessionId) return;
     void sendMessage(activeSessionId, command, activeBookId);
@@ -312,16 +277,6 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
                               content={item.part.content}
                               timestamp={msg.timestamp}
                               theme={theme}
-                              toolCall={msg.toolCall?.name === "create_book" && pendingBookArgs
-                                ? { name: msg.toolCall.name, arguments: pendingBookArgs }
-                                : msg.toolCall}
-                              onArgsChange={msg.toolCall?.name === "create_book"
-                                ? (args) => setPendingBookArgs(args)
-                                : undefined}
-                              onConfirm={msg.toolCall?.name === "create_book"
-                                ? () => void onCreateBook()
-                                : undefined}
-                              confirming={msg.toolCall?.name === "create_book" ? bookCreating : undefined}
                             />
                           );
                         }
@@ -336,16 +291,6 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
                     content={msg.content}
                     timestamp={msg.timestamp}
                     theme={theme}
-                    toolCall={msg.toolCall?.name === "create_book" && pendingBookArgs
-                      ? { name: msg.toolCall.name, arguments: pendingBookArgs }
-                      : msg.toolCall}
-                    onArgsChange={msg.toolCall?.name === "create_book"
-                      ? (args) => setPendingBookArgs(args)
-                      : undefined}
-                    onConfirm={msg.toolCall?.name === "create_book"
-                      ? () => void onCreateBook()
-                      : undefined}
-                    confirming={msg.toolCall?.name === "create_book" ? bookCreating : undefined}
                   />
                 )}
               </div>
@@ -362,22 +307,6 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
               </Message>
             )}
 
-            {/* Book creation progress */}
-            {bookCreating && (
-              <div className="flex gap-3 items-start">
-                <div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
-                  <Loader2 size={14} className="text-primary animate-spin" />
-                </div>
-                <div className="bg-card border border-border/50 px-4 py-3 rounded-2xl rounded-tl-sm text-sm space-y-1">
-                  <div className="font-medium text-foreground">{isZh ? "\u6B63\u5728\u521B\u5EFA\u4E66\u7C4D..." : "Creating book..."}</div>
-                  {createProgress && (
-                    <div className="text-xs text-muted-foreground font-mono truncate max-w-md">
-                      {createProgress}
-                    </div>
-                  )}
-                </div>
-              </div>
-            )}
           </div>
         )}
       </div>
@@ -396,42 +325,6 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
       {/* Input area */}
       <div className="shrink-0 border-t border-border/40 px-4 py-3">
         <div className="max-w-3xl mx-auto">
-          {pendingBookArgs && !loading ? (
-            /* create_book tool call pending — show action buttons */
-            <div className="flex items-center gap-3">
-              <button
-                type="button"
-                onClick={() => void onCreateBook()}
-                disabled={bookCreating}
-                className="flex items-center gap-1.5 px-5 py-2.5 rounded-xl bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 active:scale-[0.98] transition-all disabled:opacity-50"
-              >
-                {bookCreating && <Loader2 size={14} className="animate-spin" />}
-                {bookCreating ? "创建中…" : "开始写这本书"}
-              </button>
-              <div className="flex-1 flex items-center gap-2 rounded-xl border border-border/40 bg-secondary/30 px-3 py-2">
-                <input
-                  type="text"
-                  value={input}
-                  onChange={(e) => setInput(e.target.value)}
-                  onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSend(input); } }}
-                  placeholder={isZh ? "或输入修改要求…" : "Or type changes..."}
-                  disabled={!activeSessionId}
-                  className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/50"
-                />
-                {input.trim() && (
-                  <button
-                    type="button"
-                    onClick={() => onSend(input)}
-                    disabled={!activeSessionId}
-                    className="w-7 h-7 rounded-lg bg-primary text-primary-foreground flex items-center justify-center shrink-0 hover:scale-105 active:scale-95 transition-all"
-                  >
-                    <ArrowUp size={12} strokeWidth={2.5} />
-                  </button>
-                )}
-              </div>
-            </div>
-          ) : (
-            /* Normal input */
             <div className="rounded-xl bg-secondary/30 transition-all">
               <div className="flex items-center gap-2 px-3 py-2">
                 <textarea
@@ -482,7 +375,6 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
                 )}
               </div>
             </div>
-          )}
         </div>
       </div>
     </div>

+ 0 - 3
packages/studio/src/store/chat/selectors.ts

@@ -3,9 +3,6 @@ import type { ChatState } from "./types";
 const EMPTY_MESSAGES: readonly [] = [];
 
 export const chatSelectors = {
-  hasPendingTool: (s: ChatState) =>
-    Boolean(s.activeSessionId && s.sessions[s.activeSessionId]?.pendingBookArgs),
-  isCreating: (s: ChatState) => s.bookCreating,
   activeSession: (s: ChatState) => (s.activeSessionId ? s.sessions[s.activeSessionId] ?? null : null),
   activeMessages: (s: ChatState) =>
     (s.activeSessionId ? s.sessions[s.activeSessionId]?.messages : undefined) ?? EMPTY_MESSAGES,

+ 2 - 81
packages/studio/src/store/chat/slices/create/action.ts

@@ -1,89 +1,10 @@
 import type { StateCreator } from "zustand";
-import type { ChatStore, CreateActions, AgentResponse } from "../../types";
-import { fetchJson } from "../../../../hooks/use-api";
-import { bookKey } from "../message/runtime";
-
-export const createCreateSlice: StateCreator<ChatStore, [], [], CreateActions> = (set, get) => ({
-  setPendingBookArgs: (args) =>
-    set((state) => {
-      if (!state.activeSessionId) return {};
-      return {
-        sessions: {
-          ...state.sessions,
-          [state.activeSessionId]: {
-            ...state.sessions[state.activeSessionId],
-            pendingBookArgs: args,
-          },
-        },
-      };
-    }),
-  setBookCreating: (creating) => set({ bookCreating: creating }),
-  setCreateProgress: (progress) => set({ createProgress: progress }),
+import type { ChatStore, CreateActions } from "../../types";
 
+export const createCreateSlice: StateCreator<ChatStore, [], [], CreateActions> = (set) => ({
   bumpBookDataVersion: () => set((s) => ({ bookDataVersion: s.bookDataVersion + 1 })),
   openArtifact: (file) => set({ sidebarView: "artifact", artifactFile: file, artifactChapter: null }),
   openChapterArtifact: (chapterNum) => set({ sidebarView: "artifact", artifactFile: null, artifactChapter: chapterNum }),
   closeArtifact: () => set({ sidebarView: "panel", artifactFile: null, artifactChapter: null }),
   setBookSummary: (summary) => set({ bookSummary: summary }),
-
-  handleCreateBook: async (sessionId, activeBookId) => {
-    const session = get().sessions[sessionId];
-    if (!session?.pendingBookArgs) return null;
-
-    set({ bookCreating: true });
-    try {
-      const data = await fetchJson<AgentResponse>("/agent", {
-        method: "POST",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ instruction: "/create", activeBookId, sessionId }),
-      });
-      const newBookId = data.session?.activeBookId ?? null;
-      if (newBookId) {
-        set((state) => {
-          const session = state.sessions[sessionId];
-          if (!session) {
-            return {};
-          }
-
-          const previousIds = state.sessionIdsByBook[bookKey(session.bookId)] ?? [];
-          const nextIds = state.sessionIdsByBook[newBookId] ?? [];
-
-          return {
-            sessions: {
-              ...state.sessions,
-              [sessionId]: {
-                ...session,
-                bookId: newBookId,
-                pendingBookArgs: null,
-              },
-            },
-            sessionIdsByBook: {
-              ...state.sessionIdsByBook,
-              [bookKey(session.bookId)]: previousIds.filter((id) => id !== sessionId),
-              [newBookId]: nextIds.includes(sessionId) ? nextIds : [sessionId, ...nextIds],
-            },
-          };
-        });
-        get().bumpBookDataVersion();
-      }
-      set((state) => {
-        if (!state.sessions[sessionId]) return {};
-        return {
-          sessions: {
-            ...state.sessions,
-            [sessionId]: {
-              ...state.sessions[sessionId],
-              pendingBookArgs: null,
-            },
-          },
-        };
-      });
-      return newBookId;
-    } catch (e) {
-      get().addErrorMessage(sessionId, e instanceof Error ? e.message : String(e));
-      return null;
-    } finally {
-      set({ bookCreating: false });
-    }
-  },
 });

+ 0 - 2
packages/studio/src/store/chat/slices/create/initialState.ts

@@ -1,8 +1,6 @@
 import type { CreateState } from "../../types";
 
 export const initialCreateState: CreateState = {
-  bookCreating: false,
-  createProgress: "",
   bookDataVersion: 0,
   sidebarView: "panel",
   artifactFile: null,

+ 0 - 7
packages/studio/src/store/chat/slices/message/action.ts

@@ -385,13 +385,6 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
             })),
           }));
         }
-        if (toolCall?.name === "create_book") {
-          set((state) => ({
-            sessions: updateSession(state.sessions, sessionId, () => ({
-              pendingBookArgs: { ...toolCall.arguments },
-            })),
-          }));
-        }
       } else {
         const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
         if (hasStream) {

+ 0 - 1
packages/studio/src/store/chat/slices/message/runtime.ts

@@ -139,7 +139,6 @@ export function createSessionRuntime(input: {
     stream: null,
     isStreaming: false,
     lastError: null,
-    pendingBookArgs: null,
     isDraft: input.isDraft ?? false,
   };
 }

+ 0 - 7
packages/studio/src/store/chat/types.ts

@@ -110,7 +110,6 @@ export interface SessionRuntime {
   readonly stream: EventSource | null;
   readonly isStreaming: boolean;
   readonly lastError: string | null;
-  readonly pendingBookArgs: Record<string, unknown> | null;
   // 仅前端存在、尚未持久化到磁盘的草稿会话。发送第一条消息时才调 POST /sessions 把它落盘。
   readonly isDraft: boolean;
 }
@@ -125,8 +124,6 @@ export interface MessageState {
 }
 
 export interface CreateState {
-  bookCreating: boolean;
-  createProgress: string;
   bookDataVersion: number;
   sidebarView: "panel" | "artifact";
   artifactFile: string | null;         // foundation file name, e.g. "story_bible.md"
@@ -158,10 +155,6 @@ export interface MessageActions {
 }
 
 export interface CreateActions {
-  setPendingBookArgs: (args: Record<string, unknown> | null) => void;
-  setBookCreating: (creating: boolean) => void;
-  setCreateProgress: (progress: string) => void;
-  handleCreateBook: (sessionId: string, activeBookId?: string) => Promise<string | null>;
   bumpBookDataVersion: () => void;
   openArtifact: (file: string) => void;
   openChapterArtifact: (chapterNum: number) => void;