Просмотр исходного кода

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>
fanghanjun 6 месяцев назад
Родитель
Сommit
81e66654a7

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

@@ -169,4 +169,277 @@ describe("pipeline.reviseFoundation", () => {
       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("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 });
+    }
+  });
 });

+ 62 - 31
packages/core/src/agents/architect.ts

@@ -2,7 +2,7 @@ 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";
 
@@ -507,11 +507,25 @@ Rules:
 - **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");
     const outlineDir = join(storyDir, "outline");
@@ -559,6 +573,17 @@ Rules:
         writes.push(writeFile(join(outlineDir, rhythmFileName), rhythmPrinciples, "utf-8"));
       }
 
+      // revise 模式下先清空旧 role 文件,再写本次输出——避免改名/删除/合并角色
+      // 后的旧卡片残留被 readRoleCards 当作有效角色继续注入(见 Bug 3)。
+      // 备份由上游 runner.reviseFoundation 在调用前完成,这里可以安全清空。
+      // init 模式下目录本来就是空的,不需要清。
+      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 });
+      }
+
       // 一人一卡
       for (const role of roles) {
         const targetDir = role.tier === "major" ? rolesMajorDir : rolesMinorDir;
@@ -602,41 +627,47 @@ Rules:
       ));
     }
 
-    // current_state.md — Phase 5 下 architect 不再产出结构化初始状态,给个占位 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) {
+    // 运行时状态文件——**只在 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",
+        ));
+      }
+      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",
+      ));
       writes.push(writeFile(
-        join(storyDir, "particle_ledger.md"),
+        join(storyDir, "emotional_arcs.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",
+          ? "# 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",
       ));
     }
-    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",
-    ));
-    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",
-    ));
 
     await Promise.all(writes);
   }

+ 103 - 18
packages/core/src/pipeline/runner.ts

@@ -35,7 +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 } from "../utils/outline-paths.js";
+import { readStoryFrame, readVolumeMap, readCharacterContext, readCurrentStateWithFallback, isNewLayoutBook } from "../utils/outline-paths.js";
 import {
   parseStateDegradedReviewNote,
   resolveStateDegradedBaseStatus,
@@ -515,20 +515,36 @@ export class PipelineRunner {
   }
 
   /**
-   * Revise an existing book's foundation — read the legacy 条目式 架构稿, ask architect
-   * to re-emit as Phase 5 段落式 + 一人一卡 roles 目录, back up the originals under
-   * story/.backup-phase4-<timestamp>/, and overwrite the legacy files with shims.
+   * 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. Back up the original legacy files before overwriting
+    // 1. 备份架构稿相关文件(不包含运行时状态文件,那些不会被动)
     const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
-    const backupDir = join(storyDir, `.backup-phase4-${timestamp}`);
+    const backupTag = isPhase5 ? "phase5" : "phase4";
+    const backupDir = join(storyDir, `.backup-${backupTag}-${timestamp}`);
     await mkdir(backupDir, { recursive: true });
-    const legacyFiles = ["story_bible.md", "volume_outline.md", "book_rules.md", "character_matrix.md"];
-    for (const fileName of legacyFiles) {
+
+    // 备份 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");
@@ -537,16 +553,40 @@ export class PipelineRunner {
       }
     }
 
-    // 2. Load the original legacy content to feed the architect
+    // 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);
-    const [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(() => ""),
-    ]);
+    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. Call architect with reviseFrom option → new Phase 5 foundation
+    // 3. 架构师按 reviseFrom + feedback 重写
     const architect = new ArchitectAgent(this.agentCtxFor("architect", bookId));
     const foundation = await architect.generateFoundation(book, undefined, undefined, {
       reviseFrom: {
@@ -558,7 +598,7 @@ export class PipelineRunner {
       },
     });
 
-    // 4. Optional: run foundation-reviewer once — warn on failure, don't block
+    // 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 {
@@ -578,7 +618,7 @@ export class PipelineRunner {
       );
     }
 
-    // 5. Ensure Phase 5 目录存在,然后写新文件(覆盖旧的 flat md 为 shim)
+    // 5. 目录补全 → 写新文件(mode="revise" 不动运行时状态 + 清空旧 role 文件)
     const outlineDir = join(storyDir, "outline");
     await mkdir(outlineDir, { recursive: true });
     await mkdir(join(storyDir, "roles", "主要角色"), { recursive: true });
@@ -590,9 +630,54 @@ export class PipelineRunner {
       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,