Browse Source

refactor: update core agents for bilingual logging and v2 pipeline

- Writer, architect, continuity, reviser: bilingual log messages
- Composer, planner: updated memory retrieval integration
- Pipeline runner: v2 governance flow improvements
- Chapter analyzer: countingMode pass-through
- Memory retrieval: governed context improvements
- All test mocks updated for maxTokensCap field
Ma 7 months ago
parent
commit
f13d17a41a
31 changed files with 5150 additions and 510 deletions
  1. 214 2
      packages/core/src/__tests__/chapter-analyzer.test.ts
  2. 47 0
      packages/core/src/__tests__/composer.test.ts
  3. 156 1
      packages/core/src/__tests__/continuity.test.ts
  4. 1 1
      packages/core/src/__tests__/length-normalizer.test.ts
  5. 347 1
      packages/core/src/__tests__/memory-retrieval.test.ts
  6. 2 2
      packages/core/src/__tests__/models.test.ts
  7. 1 1
      packages/core/src/__tests__/pipeline-agent.test.ts
  8. 1510 75
      packages/core/src/__tests__/pipeline-runner.test.ts
  9. 206 0
      packages/core/src/__tests__/planner.test.ts
  10. 14 0
      packages/core/src/__tests__/post-write-validator.test.ts
  11. 259 6
      packages/core/src/__tests__/reviser.test.ts
  12. 1 1
      packages/core/src/__tests__/scheduler.test.ts
  13. 59 1
      packages/core/src/__tests__/state-manager.test.ts
  14. 42 0
      packages/core/src/__tests__/writer-parser.test.ts
  15. 167 4
      packages/core/src/__tests__/writer.test.ts
  16. 301 78
      packages/core/src/agents/architect.ts
  17. 264 17
      packages/core/src/agents/chapter-analyzer.ts
  18. 6 0
      packages/core/src/agents/composer.ts
  19. 301 130
      packages/core/src/agents/continuity.ts
  20. 138 15
      packages/core/src/agents/planner.ts
  21. 121 35
      packages/core/src/agents/reviser.ts
  22. 13 0
      packages/core/src/agents/rules-reader.ts
  23. 52 9
      packages/core/src/agents/writer-parser.ts
  24. 1 1
      packages/core/src/index.ts
  25. 1 1
      packages/core/src/models/project.ts
  26. 4 4
      packages/core/src/pipeline/agent.ts
  27. 609 94
      packages/core/src/pipeline/runner.ts
  28. 48 6
      packages/core/src/state/manager.ts
  29. 27 4
      packages/core/src/utils/chapter-splitter.ts
  30. 23 3
      packages/core/src/utils/governed-context.ts
  31. 215 18
      packages/core/src/utils/memory-retrieval.ts

+ 214 - 2
packages/core/src/__tests__/chapter-analyzer.test.ts

@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, it, vi } from "vitest";
 import { afterEach, describe, expect, it, vi } from "vitest";
-import { mkdtemp, rm } from "node:fs/promises";
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { join } from "node:path";
 import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
 import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
@@ -28,7 +28,7 @@ describe("ChapterAnalyzerAgent", () => {
         defaults: {
         defaults: {
           temperature: 0.7,
           temperature: 0.7,
           maxTokens: 4096,
           maxTokens: 4096,
-          thinkingBudget: 0,
+          thinkingBudget: 0, maxTokensCap: null,
           extra: {},
           extra: {},
         },
         },
       },
       },
@@ -101,4 +101,216 @@ describe("ChapterAnalyzerAgent", () => {
       await rm(bookDir, { recursive: true, force: true });
       await rm(bookDir, { recursive: true, force: true });
     }
     }
   });
   });
+
+  it("uses English prompts when analyzing imported English chapters", async () => {
+    const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-en-"));
+    const englishContent = "He looked at the sky and waited.";
+    const agent = new ChapterAnalyzerAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "english-book",
+      title: "English Book",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 10,
+      chapterWordCount: 2200,
+      language: "en",
+      createdAt: "2026-03-22T00:00:00.000Z",
+      updatedAt: "2026-03-22T00:00:00.000Z",
+    };
+
+    const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== CHAPTER_TITLE ===",
+          "A Quiet Sky",
+          "",
+          "=== CHAPTER_CONTENT ===",
+          englishContent,
+          "",
+          "=== PRE_WRITE_CHECK ===",
+          "",
+          "=== POST_SETTLEMENT ===",
+          "",
+          "=== UPDATED_STATE ===",
+          "| Field | Value |",
+          "| --- | --- |",
+          "| Current Chapter | 1 |",
+          "",
+          "=== UPDATED_LEDGER ===",
+          "",
+          "=== UPDATED_HOOKS ===",
+          "| hook_id | status |",
+          "| --- | --- |",
+          "| h1 | open |",
+          "",
+          "=== CHAPTER_SUMMARY ===",
+          "| 1 | A Quiet Sky |",
+          "",
+          "=== UPDATED_SUBPLOTS ===",
+          "",
+          "=== UPDATED_EMOTIONAL_ARCS ===",
+          "",
+          "=== UPDATED_CHARACTER_MATRIX ===",
+          "",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    try {
+      await agent.analyzeChapter({
+        book,
+        bookDir,
+        chapterNumber: 1,
+        chapterContent: englishContent,
+        chapterTitle: "A Quiet Sky",
+      });
+
+      const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
+      expect(messages[0]?.content).toContain("ALL output MUST be in English");
+      expect(messages[1]?.content).toContain("Analyze chapter 1");
+      expect(messages[1]?.content).toContain("## Chapter Content");
+      expect(messages[1]?.content).toContain("## Current State");
+      expect(messages[1]?.content).not.toContain("请分析第1章正文");
+    } finally {
+      await rm(bookDir, { recursive: true, force: true });
+    }
+  });
+
+  it("uses a retrieved summary snapshot instead of full long-history chapter summaries", async () => {
+    const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-memory-"));
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "# Chapter Summaries",
+          "",
+          "| Chapter | Title | Characters | Key Events | State Changes | Hook Activity | Mood | Chapter Type |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 1 | Guild Trail | Lin Yue | Merchant guild flees west | Route clues only | guild-route seeded | tense | action |",
+          "| 99 | Mentor Oath | Lin Yue, Mentor Shen | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "# Pending Hooks",
+          "",
+          "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
+          "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still carries the oath token.\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nReturn to the mentor oath conflict.\n", "utf-8"),
+    ]);
+
+    const agent = new ChapterAnalyzerAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== CHAPTER_TITLE ===",
+          "Mentor Oath Returns",
+          "",
+          "=== CHAPTER_CONTENT ===",
+          "Lin Yue returned to the mentor oath and the missing explanation.",
+          "",
+          "=== PRE_WRITE_CHECK ===",
+          "",
+          "=== POST_SETTLEMENT ===",
+          "",
+          "=== UPDATED_STATE ===",
+          "| Field | Value |",
+          "| --- | --- |",
+          "| Current Chapter | 100 |",
+          "",
+          "=== UPDATED_LEDGER ===",
+          "",
+          "=== UPDATED_HOOKS ===",
+          "| hook_id | status |",
+          "| --- | --- |",
+          "| h1 | open |",
+          "",
+          "=== CHAPTER_SUMMARY ===",
+          "| 100 | Mentor Oath Returns |",
+          "",
+          "=== UPDATED_SUBPLOTS ===",
+          "",
+          "=== UPDATED_EMOTIONAL_ARCS ===",
+          "",
+          "=== UPDATED_CHARACTER_MATRIX ===",
+          "",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    const book: BookConfig = {
+      id: "english-book",
+      title: "English Book",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 120,
+      chapterWordCount: 2200,
+      language: "en",
+      createdAt: "2026-03-22T00:00:00.000Z",
+      updatedAt: "2026-03-22T00:00:00.000Z",
+    };
+
+    try {
+      await agent.analyzeChapter({
+        book,
+        bookDir,
+        chapterNumber: 100,
+        chapterTitle: "Mentor Oath Returns",
+        chapterContent: "Lin Yue returned to the mentor oath and the missing explanation.",
+      });
+
+      const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
+      const userPrompt = messages[1]?.content ?? "";
+
+      expect(userPrompt).toContain("| 99 | Mentor Oath |");
+      expect(userPrompt).not.toContain("| 1 | Guild Trail |");
+    } finally {
+      await rm(bookDir, { recursive: true, force: true });
+    }
+  });
 });
 });

+ 47 - 0
packages/core/src/__tests__/composer.test.ts

@@ -256,4 +256,51 @@ describe("ComposerAgent", () => {
     expect(factEntry?.excerpt).toContain("Current Conflict");
     expect(factEntry?.excerpt).toContain("Current Conflict");
     expect(factEntry?.excerpt).toContain("Mentor debt with the vanished teacher");
     expect(factEntry?.excerpt).toContain("Mentor debt with the vanished teacher");
   });
   });
+
+  it("adds relevant volume-summary evidence for long-span retrieval after consolidation", async () => {
+    await writeFile(
+      join(storyDir, "volume_summaries.md"),
+      [
+        "# Volume Summaries",
+        "",
+        "## Volume 1 (Ch.1-40)",
+        "",
+        "Lin Yue's mentor oath becomes the core unresolved debt, while the guild route keeps trying to pull him away from the mainline.",
+        "",
+        "## Volume 2 (Ch.41-80)",
+        "",
+        "The guild route dominates logistics noise, but the mentor debt recedes into the background.",
+        "",
+      ].join("\n"),
+      "utf-8",
+    );
+
+    const composer = new ComposerAgent({
+      client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await composer.composeChapter({
+      book,
+      bookDir,
+      chapterNumber: 81,
+      plan: {
+        ...plan,
+        intent: {
+          ...plan.intent,
+          chapter: 81,
+          goal: "Bring the focus back to the mentor oath conflict.",
+        },
+      },
+    });
+
+    const volumeEntry = result.contextPackage.selectedContext.find((entry) =>
+      entry.source.startsWith("story/volume_summaries.md#"),
+    );
+
+    expect(volumeEntry).toBeDefined();
+    expect(volumeEntry?.excerpt).toContain("mentor oath");
+  });
 });
 });

+ 156 - 1
packages/core/src/__tests__/continuity.test.ts

@@ -15,6 +15,161 @@ describe("ContinuityAuditor", () => {
     vi.restoreAllMocks();
     vi.restoreAllMocks();
   });
   });
 
 
+  it("prefers book language override when building audit prompts", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-auditor-lang-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: "english-book",
+          title: "English Book",
+          genre: "xuanhuan",
+          platform: "royalroad",
+          chapterWordCount: 800,
+          targetChapters: 60,
+          status: "active",
+          language: "en",
+          createdAt: "2026-03-23T00:00:00.000Z",
+          updatedAt: "2026-03-23T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue keeps the oath token hidden.\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
+      writeFile(join(storyDir, "subplot_board.md"), "# Subplot Board\n", "utf-8"),
+      writeFile(join(storyDir, "emotional_arcs.md"), "# Emotional Arcs\n", "utf-8"),
+      writeFile(join(storyDir, "character_matrix.md"), "# Character Matrix\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nReturn to the mentor debt.\n", "utf-8"),
+      writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
+    ]);
+
+    const auditor = new ContinuityAuditor({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const chatSpy = vi.spyOn(ContinuityAuditor.prototype as never, "chat" as never).mockResolvedValue({
+      content: JSON.stringify({
+        passed: true,
+        issues: [],
+        summary: "ok",
+      }),
+      usage: ZERO_USAGE,
+    });
+
+    try {
+      await auditor.auditChapter(bookDir, "Chapter body.", 1, "xuanhuan");
+
+      const messages = chatSpy.mock.calls[0]?.[0] as
+        | ReadonlyArray<{ content: string }>
+        | undefined;
+      const systemPrompt = messages?.[0]?.content ?? "";
+
+      expect(systemPrompt).toContain("ALL OUTPUT MUST BE IN ENGLISH");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("localizes English audit prompts instead of mixing Chinese control text", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-auditor-en-prompt-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: "english-book",
+          title: "English Book",
+          genre: "other",
+          platform: "royalroad",
+          chapterWordCount: 800,
+          targetChapters: 60,
+          status: "active",
+          language: "en",
+          createdAt: "2026-03-23T00:00:00.000Z",
+          updatedAt: "2026-03-23T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara keeps the warehouse key hidden.\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
+      writeFile(join(storyDir, "subplot_board.md"), "# Subplot Board\n", "utf-8"),
+      writeFile(join(storyDir, "emotional_arcs.md"), "# Emotional Arcs\n", "utf-8"),
+      writeFile(join(storyDir, "character_matrix.md"), "# Character Matrix\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nCheck Warehouse 9.\n", "utf-8"),
+      writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
+    ]);
+
+    const auditor = new ContinuityAuditor({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const chatSpy = vi.spyOn(ContinuityAuditor.prototype as never, "chat" as never).mockResolvedValue({
+      content: JSON.stringify({
+        passed: true,
+        issues: [],
+        summary: "ok",
+      }),
+      usage: ZERO_USAGE,
+    });
+
+    try {
+      await auditor.auditChapter(bookDir, "Chapter body.", 1, "other");
+
+      const messages = chatSpy.mock.calls[0]?.[0] as
+        | ReadonlyArray<{ content: string }>
+        | undefined;
+      const systemPrompt = messages?.[0]?.content ?? "";
+      const userPrompt = messages?.[1]?.content ?? "";
+
+      expect(systemPrompt).toContain("Hook Check");
+      expect(systemPrompt).toContain("Outline Drift Check");
+      expect(systemPrompt).not.toContain("伏笔检查");
+      expect(systemPrompt).not.toContain("大纲偏离检测");
+
+      expect(userPrompt).toContain("Review chapter 1.");
+      expect(userPrompt).toContain("## Current State Card");
+      expect(userPrompt).toContain("## Pending Hooks");
+      expect(userPrompt).not.toContain("请审查第1章");
+      expect(userPrompt).not.toContain("## 当前状态卡");
+      expect(userPrompt).not.toContain("## 伏笔池");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("uses selected summary and hook evidence instead of full long-history markdown in governed mode", async () => {
   it("uses selected summary and hook evidence instead of full long-history markdown in governed mode", async () => {
     const root = await mkdtemp(join(tmpdir(), "inkos-auditor-test-"));
     const root = await mkdtemp(join(tmpdir(), "inkos-auditor-test-"));
     const bookDir = join(root, "book");
     const bookDir = join(root, "book");
@@ -62,7 +217,7 @@ describe("ContinuityAuditor", () => {
         defaults: {
         defaults: {
           temperature: 0.7,
           temperature: 0.7,
           maxTokens: 4096,
           maxTokens: 4096,
-          thinkingBudget: 0,
+          thinkingBudget: 0, maxTokensCap: null,
           extra: {},
           extra: {},
         },
         },
       },
       },

+ 1 - 1
packages/core/src/__tests__/length-normalizer.test.ts

@@ -18,7 +18,7 @@ const AGENT_CONTEXT = {
     defaults: {
     defaults: {
       temperature: 0.7,
       temperature: 0.7,
       maxTokens: 4096,
       maxTokens: 4096,
-      thinkingBudget: 0,
+      thinkingBudget: 0, maxTokensCap: null,
       extra: {},
       extra: {},
     },
     },
   } as const,
   } as const,

+ 347 - 1
packages/core/src/__tests__/memory-retrieval.test.ts

@@ -1,8 +1,10 @@
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
 import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
 import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { join } from "node:path";
+import * as memoryRetrieval from "../utils/memory-retrieval.js";
 import { retrieveMemorySelection } from "../utils/memory-retrieval.js";
 import { retrieveMemorySelection } from "../utils/memory-retrieval.js";
+import { MemoryDB } from "../state/memory-db.js";
 
 
 describe("retrieveMemorySelection", () => {
 describe("retrieveMemorySelection", () => {
   let root = "";
   let root = "";
@@ -12,6 +14,8 @@ describe("retrieveMemorySelection", () => {
       await rm(root, { recursive: true, force: true });
       await rm(root, { recursive: true, force: true });
       root = "";
       root = "";
     }
     }
+    vi.resetModules();
+    vi.doUnmock("../state/memory-db.js");
   });
   });
 
 
   it("indexes current state facts into sqlite-backed memory selection", async () => {
   it("indexes current state facts into sqlite-backed memory selection", async () => {
@@ -61,4 +65,346 @@ describe("retrieveMemorySelection", () => {
     );
     );
     expect(result.dbPath).toContain("memory.db");
     expect(result.dbPath).toContain("memory.db");
   });
   });
+
+  it("extracts mentor-focused query terms without pulling guild-route negatives into English retrieval", () => {
+    const extractQueryTerms = (memoryRetrieval as Record<string, unknown>).extractQueryTerms as
+      | ((goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray<string>) => ReadonlyArray<string>)
+      | undefined;
+
+    expect(extractQueryTerms).toBeDefined();
+    const terms = extractQueryTerms?.(
+      "Pull focus back to the mentor debt and do not open a new frontier in this chapter.",
+      "Handle guild noise without letting the guild route overtake the mentor-debt mainline.",
+      ["Lin Yue does not abandon the mentor debt."],
+    ) ?? [];
+
+    expect(terms).toContain("mentor");
+    expect(terms).toContain("debt");
+    expect(terms).not.toContain("guild");
+    expect(terms).not.toContain("route");
+  });
+
+  it("extracts 师债-focused query terms without pulling 商会路线 negatives into Chinese retrieval", () => {
+    const extractQueryTerms = (memoryRetrieval as Record<string, unknown>).extractQueryTerms as
+      | ((goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray<string>) => ReadonlyArray<string>)
+      | undefined;
+
+    expect(extractQueryTerms).toBeDefined();
+    const terms = extractQueryTerms?.(
+      "第51章把注意力拉回师债,不让商会路线盖过主线。",
+      "处理商会噪音,但不允许商会路线盖过师债主线。",
+      ["林月不会放弃师债。"],
+    ) ?? [];
+
+    expect(terms).toContain("师债");
+    expect(terms).not.toContain("商会");
+    expect(terms).not.toContain("商会路线");
+  });
+
+  it("prefers the mentor-debt recap chapter over nearby guild-noise chapters in English retrieval", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-en-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_state.md"),
+        [
+          "| Field | Value |",
+          "| --- | --- |",
+          "| Current Chapter | 10 |",
+          "| Current Goal | Continue tracing the mentor debt |",
+          "| Current Conflict | Mentor debt mainline vs guild safe route |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| mentor-debt | 1 | relationship | open | 10 | 16 | The mentor debt remains unresolved |",
+          "| guild-route | 1 | mystery | open | 9 | 12 | The guild keeps offering a safer road |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 6 | Guild Pressure 6 | Lin Yue | Guild pressure keeps building around the safe route | Guild route remains noisy | guild-route probed | restrained | holding-pattern |",
+          "| 7 | Guild Pressure 7 | Lin Yue | Guild pressure keeps building around the safe route | Guild route remains noisy | guild-route probed | restrained | holding-pattern |",
+          "| 8 | Guild Pressure 8 | Lin Yue | Guild pressure keeps building around the safe route | Guild route remains noisy | guild-route probed | restrained | holding-pattern |",
+          "| 9 | Guild Pressure 9 | Lin Yue | Guild pressure keeps building around the safe route | Guild route remains noisy | guild-route probed | restrained | holding-pattern |",
+          "| 10 | Mentor Debt Echo 10 | Lin Yue | Lin Yue returns to the mentor debt trail and checks the oath token again | Commitment to the mentor debt hardens | mentor-debt advanced | tense | mainline |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    const result = await retrieveMemorySelection({
+      bookDir,
+      chapterNumber: 11,
+      goal: "Pull focus back to the mentor debt and do not let the guild route overtake the mainline.",
+      outlineNode: "Handle guild noise without letting the guild route overtake the mentor-debt mainline.",
+      mustKeep: ["Lin Yue does not abandon the mentor debt."],
+    });
+
+    expect(result.summaries.map((summary) => summary.chapter)).toContain(10);
+  });
+
+  it("prefers the explicit 师债回响 chapter over nearby 商会噪音 chapters in Chinese retrieval", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-zh-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_state.md"),
+        [
+          "| 字段 | 值 |",
+          "| --- | --- |",
+          "| 当前章节 | 50 |",
+          "| 当前目标 | 继续追查师债 |",
+          "| 当前冲突 | 师债主线 vs 商会安全路线 |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| mentor-debt | 1 | relationship | open | 50 | 60 | 师债真相与誓令碎片持续绑定 |",
+          "| guild-route | 1 | mystery | open | 49 | 55 | 商会安全路线仍在诱导主角偏航 |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 46 | 商会余波46 | 林月 | 林月处理商会杂务与路引试探 | 继续压住商会支线 | guild-route 试探 | 克制 | 过渡牵制 |",
+          "| 47 | 商会余波47 | 林月 | 林月处理商会杂务与路引试探 | 继续压住商会支线 | guild-route 试探 | 克制 | 过渡牵制 |",
+          "| 48 | 商会余波48 | 林月 | 林月处理商会杂务与路引试探 | 继续压住商会支线 | guild-route 试探 | 克制 | 过渡牵制 |",
+          "| 49 | 商会余波49 | 林月 | 林月处理商会杂务与路引试探 | 继续压住商会支线 | guild-route 试探 | 克制 | 过渡牵制 |",
+          "| 50 | 师债回响50 | 林月 | 林月再次追查师债线索,并核对誓令碎片痕迹 | 对师债真相的执念更强 | mentor-debt 推进 | 紧绷 | 主线推进 |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    const result = await retrieveMemorySelection({
+      bookDir,
+      chapterNumber: 51,
+      goal: "第51章把注意力拉回师债,不让商会路线盖过主线。",
+      outlineNode: "处理商会噪音,但不允许商会路线盖过师债主线。",
+      mustKeep: ["林月不会放弃师债。"],
+    });
+
+    expect(result.summaries.map((summary) => summary.chapter)).toContain(50);
+  });
+
+  it("keeps the mentor-debt recap chapter in markdown fallback mode for English books", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-en-fallback-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_state.md"),
+        [
+          "| Field | Value |",
+          "| --- | --- |",
+          "| Current Chapter | 10 |",
+          "| Current Goal | Continue tracing the mentor debt |",
+          "| Current Conflict | Mentor debt mainline vs guild safe route |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| mentor-debt | 1 | relationship | open | 10 | 16 | The mentor debt remains unresolved |",
+          "| guild-route | 1 | mystery | open | 9 | 12 | The guild keeps offering a safer road |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 6 | Guild Pressure 6 | Lin Yue | Guild pressure keeps building around the safe route | Guild route remains noisy | guild-route probed | restrained | holding-pattern |",
+          "| 7 | Guild Pressure 7 | Lin Yue | Guild pressure keeps building around the safe route | Guild route remains noisy | guild-route probed | restrained | holding-pattern |",
+          "| 8 | Guild Pressure 8 | Lin Yue | Guild pressure keeps building around the safe route | Guild route remains noisy | guild-route probed | restrained | holding-pattern |",
+          "| 9 | Guild Pressure 9 | Lin Yue | Guild pressure keeps building around the safe route | Guild route remains noisy | guild-route probed | restrained | holding-pattern |",
+          "| 10 | Mentor Debt Echo 10 | Lin Yue | Lin Yue returns to the mentor debt trail and checks the oath token again | Commitment to the mentor debt hardens | mentor-debt advanced | tense | mainline |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    vi.resetModules();
+    vi.doMock("../state/memory-db.js", () => ({
+      MemoryDB: class {
+        constructor() {
+          throw new Error("sqlite unavailable");
+        }
+      },
+    }));
+    const { retrieveMemorySelection: retrieveFallback } = await import("../utils/memory-retrieval.js");
+
+    const result = await retrieveFallback({
+      bookDir,
+      chapterNumber: 11,
+      goal: "Pull focus back to the mentor debt and do not let the guild route overtake the mainline.",
+      outlineNode: "Handle guild noise without letting the guild route overtake the mentor-debt mainline.",
+      mustKeep: ["Lin Yue does not abandon the mentor debt."],
+    });
+
+    expect(result.dbPath).toBeUndefined();
+    expect(result.summaries.map((summary) => summary.chapter)).toContain(10);
+    expect(result.summaries.at(-1)?.chapter).toBe(10);
+  });
+
+  it("keeps the 师债回响 chapter in markdown fallback mode for Chinese books", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-zh-fallback-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_state.md"),
+        [
+          "| 字段 | 值 |",
+          "| --- | --- |",
+          "| 当前章节 | 50 |",
+          "| 当前目标 | 继续追查师债 |",
+          "| 当前冲突 | 师债主线 vs 商会安全路线 |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| mentor-debt | 1 | relationship | open | 50 | 60 | 师债真相与誓令碎片持续绑定 |",
+          "| guild-route | 1 | mystery | open | 49 | 55 | 商会安全路线仍在诱导主角偏航 |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 46 | 商会余波46 | 林月 | 林月处理商会杂务与路引试探 | 继续压住商会支线 | guild-route 试探 | 克制 | 过渡牵制 |",
+          "| 47 | 商会余波47 | 林月 | 林月处理商会杂务与路引试探 | 继续压住商会支线 | guild-route 试探 | 克制 | 过渡牵制 |",
+          "| 48 | 商会余波48 | 林月 | 林月处理商会杂务与路引试探 | 继续压住商会支线 | guild-route 试探 | 克制 | 过渡牵制 |",
+          "| 49 | 商会余波49 | 林月 | 林月处理商会杂务与路引试探 | 继续压住商会支线 | guild-route 试探 | 克制 | 过渡牵制 |",
+          "| 50 | 师债回响50 | 林月 | 林月再次追查师债线索,并核对誓令碎片痕迹 | 对师债真相的执念更强 | mentor-debt 推进 | 紧绷 | 主线推进 |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    vi.resetModules();
+    vi.doMock("../state/memory-db.js", () => ({
+      MemoryDB: class {
+        constructor() {
+          throw new Error("sqlite unavailable");
+        }
+      },
+    }));
+    const { retrieveMemorySelection: retrieveFallback } = await import("../utils/memory-retrieval.js");
+
+    const result = await retrieveFallback({
+      bookDir,
+      chapterNumber: 51,
+      goal: "第51章把注意力拉回师债,不让商会路线盖过主线。",
+      outlineNode: "处理商会噪音,但不允许商会路线盖过师债主线。",
+      mustKeep: ["林月不会放弃师债。"],
+    });
+
+    expect(result.dbPath).toBeUndefined();
+    expect(result.summaries.map((summary) => summary.chapter)).toContain(50);
+    expect(result.summaries.at(-1)?.chapter).toBe(50);
+  });
+
+  it("uses existing sqlite summaries and hooks without requiring markdown truth files", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-db-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await writeFile(
+      join(storyDir, "current_state.md"),
+      [
+        "| Field | Value |",
+        "| --- | --- |",
+        "| Current Chapter | 9 |",
+        "| Current Conflict | Mentor debt mainline vs guild safe route |",
+        "",
+      ].join("\n"),
+      "utf-8",
+    );
+
+    const memoryDb = new MemoryDB(bookDir);
+    try {
+      memoryDb.upsertSummary({
+        chapter: 9,
+        title: "Mentor Debt Echo",
+        characters: "Lin Yue",
+        events: "Lin Yue returns to the mentor debt trail",
+        stateChanges: "Commitment hardens",
+        hookActivity: "mentor-debt advanced",
+        mood: "tense",
+        chapterType: "mainline",
+      });
+      memoryDb.upsertHook({
+        hookId: "mentor-debt",
+        startChapter: 1,
+        type: "relationship",
+        status: "open",
+        lastAdvancedChapter: 9,
+        expectedPayoff: "16",
+        notes: "Mentor debt remains unresolved",
+      });
+    } finally {
+      memoryDb.close();
+    }
+
+    const result = await retrieveMemorySelection({
+      bookDir,
+      chapterNumber: 10,
+      goal: "Pull focus back to the mentor debt.",
+      mustKeep: ["Lin Yue does not abandon the mentor debt."],
+    });
+
+    expect(result.dbPath).toContain("memory.db");
+    expect(result.summaries.map((summary) => summary.chapter)).toContain(9);
+    expect(result.hooks.map((hook) => hook.hookId)).toContain("mentor-debt");
+  });
 });
 });

+ 2 - 2
packages/core/src/__tests__/models.test.ts

@@ -336,9 +336,9 @@ describe("ProjectConfigSchema", () => {
     expect(result.notify).toEqual([]);
     expect(result.notify).toEqual([]);
   });
   });
 
 
-  it("defaults input governance mode to legacy", () => {
+  it("defaults input governance mode to v2", () => {
     const result = ProjectConfigSchema.parse(validProject);
     const result = ProjectConfigSchema.parse(validProject);
-    expect(result.inputGovernanceMode).toBe("legacy");
+    expect(result.inputGovernanceMode).toBe("v2");
   });
   });
 
 
   it("rejects wrong version", () => {
   it("rejects wrong version", () => {

+ 1 - 1
packages/core/src/__tests__/pipeline-agent.test.ts

@@ -25,7 +25,7 @@ describe("agent pipeline tools", () => {
         defaults: {
         defaults: {
           temperature: 0.7,
           temperature: 0.7,
           maxTokens: 4096,
           maxTokens: 4096,
-          thinkingBudget: 0,
+          thinkingBudget: 0, maxTokensCap: null,
           extra: {},
           extra: {},
         },
         },
       },
       },

+ 1510 - 75
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -15,6 +15,7 @@ import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
 import type { BookConfig } from "../models/book.js";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import { MemoryDB } from "../state/memory-db.js";
 import { MemoryDB } from "../state/memory-db.js";
+import * as memoryDbModule from "../state/memory-db.js";
 import { countChapterLength } from "../utils/length-metrics.js";
 import { countChapterLength } from "../utils/length-metrics.js";
 
 
 const ZERO_USAGE = {
 const ZERO_USAGE = {
@@ -113,6 +114,27 @@ function createStateCard(params: {
   ].join("\n");
   ].join("\n");
 }
 }
 
 
+function createCaptureLogger() {
+  const infos: string[] = [];
+  const warnings: string[] = [];
+
+  const logger = {
+    debug() {},
+    info(message: string) {
+      infos.push(message);
+    },
+    warn(message: string) {
+      warnings.push(message);
+    },
+    error() {},
+    child() {
+      return logger;
+    },
+  };
+
+  return { logger, infos, warnings };
+}
+
 async function createRunnerFixture(
 async function createRunnerFixture(
   configOverrides: Partial<ConstructorParameters<typeof PipelineRunner>[0]> = {},
   configOverrides: Partial<ConstructorParameters<typeof PipelineRunner>[0]> = {},
 ): Promise<{
 ): Promise<{
@@ -149,7 +171,7 @@ async function createRunnerFixture(
       defaults: {
       defaults: {
         temperature: 0.7,
         temperature: 0.7,
         maxTokens: 4096,
         maxTokens: 4096,
-        thinkingBudget: 0,
+        thinkingBudget: 0, maxTokensCap: null,
       },
       },
     } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
     } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
     model: "test-model",
     model: "test-model",
@@ -192,7 +214,7 @@ describe("PipelineRunner", () => {
           defaults: {
           defaults: {
             temperature: 0.7,
             temperature: 0.7,
             maxTokens: 4096,
             maxTokens: 4096,
-            thinkingBudget: 0,
+            thinkingBudget: 0, maxTokensCap: null,
           },
           },
         } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
         } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
         model: "base-model",
         model: "base-model",
@@ -268,7 +290,7 @@ describe("PipelineRunner", () => {
         defaults: {
         defaults: {
           temperature: 0.7,
           temperature: 0.7,
           maxTokens: 4096,
           maxTokens: 4096,
-          thinkingBudget: 0,
+          thinkingBudget: 0, maxTokensCap: null,
         },
         },
       } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
       } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
       model: "test-model",
       model: "test-model",
@@ -372,6 +394,73 @@ describe("PipelineRunner", () => {
     }
     }
   });
   });
 
 
+  it("reuses an existing planned intent for draft when no new context is provided in v2 mode", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "v2",
+    });
+
+    await Promise.all([
+      mkdir(join(state.bookDir(bookId), "story", "runtime"), { recursive: true }),
+      writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nTrack the merchant guild trail.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
+      writeFile(
+        join(state.bookDir(bookId), "story", "runtime", "chapter-0001.intent.md"),
+        [
+          "# Chapter Intent",
+          "",
+          "## Goal",
+          "Bring the focus back to the mentor conflict.",
+          "",
+          "## Outline Node",
+          "Track the merchant guild trail.",
+          "",
+          "## Must Keep",
+          "- Lin Yue still hides the broken oath token.",
+          "",
+          "## Must Avoid",
+          "- Do not reveal the mastermind",
+          "",
+          "## Style Emphasis",
+          "- Keep the narrative emotionally close to the mentor conflict.",
+          "",
+          "## Conflicts",
+          "- outline_vs_request: allow local outline deferral",
+          "",
+          "## Pending Hooks Snapshot",
+          "- none",
+          "",
+          "## Chapter Summaries Snapshot",
+          "- none",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
+    const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Governed draft body.",
+        wordCount: "Governed draft body.".length,
+      }),
+    );
+
+    try {
+      await runner.writeDraft(bookId);
+
+      expect(planChapter).not.toHaveBeenCalled();
+      const writeInput = writeChapter.mock.calls[0]?.[0];
+      expect(writeInput?.chapterIntent).toContain("Bring the focus back to the mentor conflict.");
+      expect(writeInput?.ruleStack?.activeOverrides).toHaveLength(1);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("syncs current-state facts into memory.db after drafting a chapter", async () => {
   it("syncs current-state facts into memory.db after drafting a chapter", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const { root, runner, state, bookId } = await createRunnerFixture();
     const chapterOneState = createStateCard({
     const chapterOneState = createStateCard({
@@ -404,7 +493,17 @@ describe("PipelineRunner", () => {
         content: "Draft body.",
         content: "Draft body.",
         wordCount: "Draft body.".length,
         wordCount: "Draft body.".length,
         updatedState: chapterOneState,
         updatedState: chapterOneState,
-        updatedHooks: "# Pending Hooks\n",
+        updatedHooks: [
+          "# Pending Hooks",
+          "",
+          "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| mentor-debt | 1 | relationship | open | 1 | 6 | The mentor debt remains unresolved |",
+          "",
+        ].join("\n"),
+        chapterSummary: [
+          "| 1 | Ferry Debt | Lin Yue | Lin Yue crosses the ferry and recommits to the mentor trail | The debt hardens into the core conflict | mentor-debt advanced | tense | mainline |",
+        ].join("\n"),
       }),
       }),
     );
     );
 
 
@@ -423,6 +522,259 @@ describe("PipelineRunner", () => {
             }),
             }),
           ]),
           ]),
         );
         );
+        expect(memoryDb.getChapterCount()).toBe(1);
+        expect(memoryDb.getActiveHooks()).toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({
+              hookId: "mentor-debt",
+              status: "open",
+            }),
+          ]),
+        );
+      } finally {
+        memoryDb.close();
+      }
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("uses a friendly fallback warning when sqlite memory indexing is unavailable", async () => {
+    const { logger, warnings } = createCaptureLogger();
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      logger,
+    });
+
+    await Promise.all([
+      writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+      writeFile(
+        join(state.bookDir(bookId), "story", "current_state.md"),
+        createStateCard({
+          chapter: 0,
+          location: "Shrine outskirts",
+          protagonistState: "Lin Yue begins with the oath token hidden.",
+          goal: "Reach the trial city.",
+          conflict: "The trial deadline is closing in.",
+        }),
+        "utf-8",
+      ),
+    ]);
+
+    vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation(() => {
+      const error = new Error("No such built-in module: node:sqlite");
+      (error as Error & { code?: string }).code = "ERR_UNKNOWN_BUILTIN_MODULE";
+      throw error;
+    });
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Draft body.",
+        wordCount: "Draft body.".length,
+      }),
+    );
+
+    try {
+      const result = await runner.writeDraft(bookId);
+
+      expect(result.chapterNumber).toBe(1);
+      expect(warnings).toContain(
+        "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。",
+      );
+      expect(warnings.join("\n")).not.toContain("node:sqlite");
+      expect(warnings.join("\n")).not.toContain("ERR_UNKNOWN_BUILTIN_MODULE");
+      expect(warnings.join("\n")).not.toContain("状态事实同步已跳过:");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("does not misclassify generic runtime errors as sqlite-unavailable fallback", async () => {
+    const { logger, warnings } = createCaptureLogger();
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      logger,
+    });
+
+    await Promise.all([
+      writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+      writeFile(
+        join(state.bookDir(bookId), "story", "current_state.md"),
+        createStateCard({
+          chapter: 0,
+          location: "Shrine outskirts",
+          protagonistState: "Lin Yue begins with the oath token hidden.",
+          goal: "Reach the trial city.",
+          conflict: "The trial deadline is closing in.",
+        }),
+        "utf-8",
+      ),
+    ]);
+
+    vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation(() => {
+      throw new Error("sync failed while handling cached node:sqlite telemetry text");
+    });
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Draft body.",
+        wordCount: "Draft body.".length,
+      }),
+    );
+
+    try {
+      const result = await runner.writeDraft(bookId);
+
+      expect(result.chapterNumber).toBe(1);
+      expect(warnings.join("\n")).toContain("叙事记忆同步已跳过:");
+      expect(warnings.join("\n")).not.toContain("当前 Node 运行时不支持 SQLite 记忆索引");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("recovers when sqlite-unavailable signature is transient and probe succeeds", async () => {
+    const { logger, warnings } = createCaptureLogger();
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      logger,
+      inputGovernanceMode: "legacy",
+    });
+
+    await Promise.all([
+      writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+      writeFile(
+        join(state.bookDir(bookId), "story", "current_state.md"),
+        createStateCard({
+          chapter: 0,
+          location: "Shrine outskirts",
+          protagonistState: "Lin Yue begins with the oath token hidden.",
+          goal: "Reach the trial city.",
+          conflict: "The trial deadline is closing in.",
+        }),
+        "utf-8",
+      ),
+    ]);
+
+    const RealMemoryDB = memoryDbModule.MemoryDB;
+    let constructorCalls = 0;
+    vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation((...args: ConstructorParameters<typeof memoryDbModule.MemoryDB>) => {
+      if (constructorCalls === 0) {
+        constructorCalls += 1;
+        const error = new Error("No such built-in module: node:sqlite");
+        (error as Error & { code?: string }).code = "ERR_UNKNOWN_BUILTIN_MODULE";
+        throw error;
+      }
+      constructorCalls += 1;
+      return new RealMemoryDB(...args);
+    });
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Draft body.",
+        wordCount: "Draft body.".length,
+        chapterSummary: "| 1 | Draft summary | Lin Yue | Draft event | Draft shift | hook advanced | tense | transition |",
+        updatedHooks: [
+          "# Pending Hooks",
+          "",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| mentor-debt | 1 | relationship | open | 1 | 3 | Draft hook |",
+        ].join("\n"),
+      }),
+    );
+
+    try {
+      const result = await runner.writeDraft(bookId);
+
+      expect(result.chapterNumber).toBe(1);
+      expect(warnings.join("\n")).not.toContain("当前 Node 运行时不支持 SQLite 记忆索引");
+      expect(warnings.join("\n")).not.toContain("叙事记忆同步已跳过");
+
+      const memoryDb = new MemoryDB(state.bookDir(bookId));
+      try {
+        expect(memoryDb.getChapterCount()).toBe(1);
+        expect(memoryDb.getActiveHooks()).toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({
+              hookId: "mentor-debt",
+              status: "open",
+            }),
+          ]),
+        );
+      } finally {
+        memoryDb.close();
+      }
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("retries transient sqlite busy errors during narrative memory sync", async () => {
+    const { logger, warnings } = createCaptureLogger();
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      logger,
+      inputGovernanceMode: "legacy",
+    });
+
+    await Promise.all([
+      writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+      writeFile(
+        join(state.bookDir(bookId), "story", "current_state.md"),
+        createStateCard({
+          chapter: 0,
+          location: "Shrine outskirts",
+          protagonistState: "Lin Yue begins with the oath token hidden.",
+          goal: "Reach the trial city.",
+          conflict: "The trial deadline is closing in.",
+        }),
+        "utf-8",
+      ),
+    ]);
+
+    const RealMemoryDB = memoryDbModule.MemoryDB;
+    let constructorCalls = 0;
+    vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation((...args: ConstructorParameters<typeof memoryDbModule.MemoryDB>) => {
+      if (constructorCalls === 0) {
+        constructorCalls += 1;
+        const error = new Error("database is locked");
+        (error as Error & { code?: string }).code = "SQLITE_BUSY";
+        throw error;
+      }
+      constructorCalls += 1;
+      return new RealMemoryDB(...args);
+    });
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Draft body.",
+        wordCount: "Draft body.".length,
+        chapterSummary: "| 1 | Draft summary | Lin Yue | Draft event | Draft shift | hook advanced | tense | transition |",
+        updatedHooks: [
+          "# Pending Hooks",
+          "",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| mentor-debt | 1 | relationship | open | 1 | 3 | Draft hook |",
+        ].join("\n"),
+      }),
+    );
+
+    try {
+      const result = await runner.writeDraft(bookId);
+
+      expect(result.chapterNumber).toBe(1);
+      expect(warnings.join("\n")).not.toContain("当前 Node 运行时不支持 SQLite 记忆索引");
+      expect(warnings.join("\n")).not.toContain("叙事记忆同步已跳过");
+
+      const memoryDb = new MemoryDB(state.bookDir(bookId));
+      try {
+        expect(memoryDb.getChapterCount()).toBe(1);
+        expect(memoryDb.getActiveHooks()).toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({
+              hookId: "mentor-debt",
+              status: "open",
+            }),
+          ]),
+        );
       } finally {
       } finally {
         memoryDb.close();
         memoryDb.close();
       }
       }
@@ -431,6 +783,63 @@ describe("PipelineRunner", () => {
     }
     }
   });
   });
 
 
+  it("logs explicit stage messages during book initialization", async () => {
+    const { logger, infos } = createCaptureLogger();
+    const { root, runner, state, bookId } = await createRunnerFixture({ logger });
+    const book = await state.loadBookConfig(bookId);
+
+    vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
+      storyBible: "# Story Bible\n",
+      volumeOutline: "# Volume Outline\n",
+      bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
+      currentState: createStateCard({
+        chapter: 0,
+        location: "Ashen ferry crossing",
+        protagonistState: "Lin Yue still hides the oath token.",
+        goal: "Find the vanished mentor.",
+        conflict: "The mentor debt is still personal.",
+      }),
+      pendingHooks: "# Pending Hooks\n",
+    });
+
+    try {
+      await runner.initBook(book);
+
+      expect(infos).toEqual(expect.arrayContaining([
+        "阶段:保存书籍配置",
+        "阶段:生成基础设定",
+        "阶段:写入基础设定文件",
+        "阶段:初始化控制文档",
+        "阶段:创建初始快照",
+      ]));
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("marks an outlining book as active after drafting the first chapter", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const book = await state.loadBookConfig(bookId);
+    await state.saveBookConfig(bookId, { ...book, status: "outlining" });
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Draft body.",
+        wordCount: "Draft body.".length,
+      }),
+    );
+
+    try {
+      await runner.writeDraft(bookId);
+
+      const book = await state.loadBookConfig(bookId);
+      expect(book.status).toBe("active");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("routes writeNextChapter through planner and composer in v2 mode", async () => {
   it("routes writeNextChapter through planner and composer in v2 mode", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture({
     const { root, runner, state, bookId } = await createRunnerFixture({
       inputGovernanceMode: "v2",
       inputGovernanceMode: "v2",
@@ -474,9 +883,11 @@ describe("PipelineRunner", () => {
     }
     }
   });
   });
 
 
-  it("passes reduced control inputs into auditor and reviser in v2 mode", async () => {
+  it("logs explicit stage messages during writeNextChapter", async () => {
+    const { logger, infos } = createCaptureLogger();
     const { root, runner, state, bookId } = await createRunnerFixture({
     const { root, runner, state, bookId } = await createRunnerFixture({
       inputGovernanceMode: "v2",
       inputGovernanceMode: "v2",
+      logger,
     });
     });
 
 
     await Promise.all([
     await Promise.all([
@@ -490,16 +901,182 @@ describe("PipelineRunner", () => {
     vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
     vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
       createWriterOutput({
       createWriterOutput({
         chapterNumber: 1,
         chapterNumber: 1,
-        content: "Needs governed revision.",
-        wordCount: "Needs governed revision.".length,
+        content: "Governed pipeline draft.",
+        wordCount: "Governed pipeline draft.".length,
       }),
       }),
     );
     );
-    const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
       createAuditResult({
       createAuditResult({
-        passed: false,
-        issues: [CRITICAL_ISSUE],
-        summary: "needs revision",
-      }),
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+
+    try {
+      await runner.writeNextChapter(bookId, 220);
+
+      expect(infos).toEqual(expect.arrayContaining([
+        "阶段:准备章节输入",
+        "阶段:撰写章节草稿",
+        "阶段:审计草稿",
+        "阶段:落盘最终章节",
+        "阶段:生成最终真相文件",
+        "阶段:校验真相文件变更",
+        "阶段:同步记忆索引",
+        "阶段:更新章节索引与快照",
+      ]));
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("logs English stage messages during writeNextChapter for English books", async () => {
+    const { logger, infos } = createCaptureLogger();
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "v2",
+      logger,
+    });
+    const englishBook = {
+      ...(await state.loadBookConfig(bookId)),
+      genre: "other",
+      language: "en" as const,
+      chapterWordCount: 220,
+    };
+
+    await state.saveBookConfig(bookId, englishBook);
+    await Promise.all([
+      writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
+    ]);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Governed pipeline draft.",
+        wordCount: countChapterLength("Governed pipeline draft.", "en_words"),
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+
+    try {
+      await runner.writeNextChapter(bookId, 220);
+
+      expect(infos).toEqual(expect.arrayContaining([
+        "Stage: preparing chapter inputs",
+        "Stage: writing chapter draft",
+        "Stage: auditing draft",
+        "Stage: persisting final chapter",
+        "Stage: rebuilding final truth files",
+        "Stage: validating truth file updates",
+        "Stage: syncing memory indexes",
+        "Stage: updating chapter index and snapshots",
+      ]));
+      expect(infos.join("\n")).not.toContain("阶段:");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("writes English audit drift correction blocks for English books", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const englishBook = {
+      ...(await state.loadBookConfig(bookId)),
+      genre: "other",
+      language: "en" as const,
+      chapterWordCount: 220,
+    };
+
+    await state.saveBookConfig(bookId, englishBook);
+    await Promise.all([
+      writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nKeep the pressure on the harbor debt.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "current_state.md"), createStateCard({
+        chapter: 0,
+        location: "Harbor gate",
+        protagonistState: "Lin Yue is tracking the vanished mentor.",
+        goal: "Reach the sealed berth.",
+        conflict: "The harbor debt keeps pulling him sideways.",
+      }), "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The harbor seal cannot be forged.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- The vanished mentor still owes a debt.\n", "utf-8"),
+    ]);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Lin Yue reached the sealed berth before dawn.",
+        wordCount: countChapterLength("Lin Yue reached the sealed berth before dawn.", "en_words"),
+        updatedState: createStateCard({
+          chapter: 1,
+          location: "Sealed berth",
+          protagonistState: "Lin Yue is winded but focused.",
+          goal: "Inspect the berth before the guild arrives.",
+          conflict: "The harbor debt is still active.",
+        }),
+        updatedHooks: "# Pending Hooks\n\n- The vanished mentor still owes a debt.\n",
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [{
+          severity: "warning",
+          category: "continuity",
+          description: "Keep the berth timing precise in the next chapter.",
+          suggestion: "Avoid skipping the dawn transition.",
+        }],
+        summary: "warning only",
+      }),
+    );
+
+    try {
+      await runner.writeNextChapter(bookId, 220);
+
+      const currentState = await readFile(join(state.bookDir(bookId), "story", "current_state.md"), "utf-8");
+      expect(currentState).toContain("## Audit Drift Correction");
+      expect(currentState).toContain("> Chapter 1 audit found the following issues");
+      expect(currentState).not.toContain("## 审计纠偏");
+      expect(currentState).not.toContain("下一章写作前参照");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("passes reduced control inputs into auditor and reviser in v2 mode", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "v2",
+    });
+
+    await Promise.all([
+      writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
+    ]);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Needs governed revision.",
+        wordCount: "Needs governed revision.".length,
+      }),
+    );
+    const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: false,
+        issues: [CRITICAL_ISSUE],
+        summary: "needs revision",
+      }),
     );
     );
     const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
     const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
       createReviseOutput({
       createReviseOutput({
@@ -763,18 +1340,87 @@ describe("PipelineRunner", () => {
 
 
       expect(normalizeChapter).toHaveBeenCalledTimes(1);
       expect(normalizeChapter).toHaveBeenCalledTimes(1);
       expect((result as { lengthWarnings?: ReadonlyArray<string> }).lengthWarnings?.[0]).toContain(
       expect((result as { lengthWarnings?: ReadonlyArray<string> }).lengthWarnings?.[0]).toContain(
-        "outside hard range",
+        "超出硬区间",
       );
       );
       expect((result as { lengthTelemetry?: { finalCount: number } }).lengthTelemetry?.finalCount).toBe(
       expect((result as { lengthTelemetry?: { finalCount: number } }).lengthTelemetry?.finalCount).toBe(
         stillOverHard.length,
         stillOverHard.length,
       );
       );
-      expect(chapterMeta?.lengthWarnings?.[0]).toContain("outside hard range");
+      expect(chapterMeta?.lengthWarnings?.[0]).toContain("超出硬区间");
       expect(chapterMeta?.lengthTelemetry?.lengthWarning).toBe(true);
       expect(chapterMeta?.lengthTelemetry?.lengthWarning).toBe(true);
     } finally {
     } finally {
       await rm(root, { recursive: true, force: true });
       await rm(root, { recursive: true, force: true });
     }
     }
   });
   });
 
 
+  it("keeps the last actionable audit issues when re-audit returns failed with no issues", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "legacy",
+    });
+    const storyDir = join(state.bookDir(bookId), "story");
+    const draftBody = "甲".repeat(210);
+    const revisedBody = "乙".repeat(215);
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 0,
+        location: "Ashen ferry crossing",
+        protagonistState: "Lin Yue still hides the oath token.",
+        goal: "Find the vanished mentor.",
+        conflict: "The mentor debt is still personal.",
+      }), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+    ]);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        content: draftBody,
+        wordCount: draftBody.length,
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [CRITICAL_ISSUE],
+          summary: "needs revision",
+        }),
+      )
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [],
+          summary: "",
+        }),
+      );
+    vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: revisedBody,
+        wordCount: revisedBody.length,
+        fixedIssues: ["- tightened continuity."],
+      }),
+    );
+    vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
+      createAnalyzedOutput({
+        content: revisedBody,
+        wordCount: revisedBody.length,
+      }),
+    );
+
+    try {
+      const result = await runner.writeNextChapter(bookId, 220);
+      const savedIndex = await state.loadChapterIndex(bookId);
+
+      expect(result.status).toBe("audit-failed");
+      expect(result.auditResult.summary).toBe("needs revision");
+      expect(result.auditResult.issues).toEqual([CRITICAL_ISSUE]);
+      expect(savedIndex[0]?.auditIssues).toEqual([
+        `[critical] ${CRITICAL_ISSUE.description}`,
+      ]);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("preserves the legacy fallback when input governance mode is legacy", async () => {
   it("preserves the legacy fallback when input governance mode is legacy", async () => {
     const { root, runner, bookId } = await createRunnerFixture({
     const { root, runner, bookId } = await createRunnerFixture({
       inputGovernanceMode: "legacy",
       inputGovernanceMode: "legacy",
@@ -1143,76 +1789,865 @@ describe("PipelineRunner", () => {
     await rm(root, { recursive: true, force: true });
     await rm(root, { recursive: true, force: true });
   });
   });
 
 
-  it("rebuilds current facts from the revised chapter snapshot", async () => {
+  it("imports English chapters with English foundation seeds and persistence files", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const { root, runner, state, bookId } = await createRunnerFixture();
-    const storyDir = join(state.bookDir(bookId), "story");
-    const chaptersDir = join(state.bookDir(bookId), "chapters");
-    const oldState = createStateCard({
-      chapter: 1,
-      location: "Ashen ferry crossing",
-      protagonistState: "Lin Yue still hides the oath token.",
-      goal: "Find the vanished mentor.",
-      conflict: "The mentor debt is still personal.",
-    });
-    const revisedState = createStateCard({
-      chapter: 1,
-      location: "Ashen ferry crossing",
-      protagonistState: "Lin Yue no longer hides the oath token.",
-      goal: "Confront the vanished mentor.",
-      conflict: "The oath token is public now, forcing the confrontation.",
-    });
+    const englishBook = {
+      ...(await state.loadBookConfig(bookId)),
+      genre: "other",
+      language: "en" as const,
+      chapterWordCount: 2200,
+    };
 
 
-    await Promise.all([
-      writeFile(join(chaptersDir, "0001_Test_Chapter.md"), "# 第1章 Test Chapter\n\nOriginal body.", "utf-8"),
-      writeFile(join(storyDir, "current_state.md"), oldState, "utf-8"),
-      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
-    ]);
-    await state.saveChapterIndex(bookId, [{
-      number: 1,
-      title: "Test Chapter",
-      status: "audit-failed",
-      wordCount: "Original body.".length,
-      createdAt: "2026-03-19T00:00:00.000Z",
-      updatedAt: "2026-03-19T00:00:00.000Z",
-      auditIssues: [],
-      lengthWarnings: [],
-    }]);
-    await state.snapshotState(bookId, 1);
+    await state.saveBookConfig(bookId, englishBook);
 
 
-    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
-      createAuditResult({
-        passed: false,
-        issues: [CRITICAL_ISSUE],
-        summary: "needs revision",
+    const foundation = vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
+      storyBible: "# Story Bible\n",
+      volumeOutline: "# Volume Outline\n",
+      bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
+      currentState: createStateCard({
+        chapter: 0,
+        location: "Harbor gate",
+        protagonistState: "Mara arrives with a sealed letter.",
+        goal: "Find the missing captain before sunrise.",
+        conflict: "The harbor watch is searching every ship.",
       }),
       }),
-    );
-    vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
-      createReviseOutput({
-        revisedContent: "Revised body.",
-        wordCount: "Revised body.".length,
-        updatedState: revisedState,
-        updatedHooks: "# Pending Hooks\n",
+      pendingHooks: "# Pending Hooks\n\n| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |\n| --- | --- | --- | --- | --- | --- | --- |\n",
+    });
+    const saveChapter = vi.spyOn(WriterAgent.prototype, "saveChapter");
+
+    vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
+      createAnalyzedOutput({
+        chapterNumber: 1,
+        title: "Prelude",
+        content: "A cold wind crossed the harbor.",
+        wordCount: countChapterLength("A cold wind crossed the harbor.", "en_words"),
+        updatedState: createStateCard({
+          chapter: 1,
+          location: "Harbor gate",
+          protagonistState: "Mara hides the sealed letter under her coat.",
+          goal: "Slip past the harbor watch.",
+          conflict: "The watch now searches for the missing captain's courier.",
+        }),
+        updatedHooks: "# Pending Hooks\n\n| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |\n| --- | --- | --- | --- | --- | --- | --- |\n| captain-letter | 1 | mystery | open | 1 | The captain's disappearance is explained. | The sealed letter points to the vanished captain. |\n",
+        chapterSummary: "| 1 | Prelude | Mara | Mara reaches the harbor with a sealed letter. | Mara hides the letter and studies the watch patrol. | The captain-letter mystery opens. | tense | setup |",
+        updatedSubplots: [
+          "# Subplot Board",
+          "",
+          "| Subplot | Status | Note |",
+          "| --- | --- | --- |",
+          "| Harbor search | Active | Mara begins the search for the missing captain. |",
+          "",
+        ].join("\n"),
+        updatedEmotionalArcs: "",
+        updatedCharacterMatrix: "",
       }),
       }),
     );
     );
 
 
     try {
     try {
-      await runner.reviseDraft(bookId, 1);
+      await runner.importChapters({
+        bookId,
+        chapters: [
+          { title: "Prelude", content: "A cold wind crossed the harbor." },
+        ],
+      });
 
 
-      const memoryDb = new MemoryDB(state.bookDir(bookId));
-      try {
-        expect(memoryDb.getCurrentFacts()).toEqual(
-          expect.arrayContaining([
-            expect.objectContaining({
-              predicate: "Current Conflict",
-              object: "The oath token is public now, forcing the confrontation.",
-              validFromChapter: 1,
-              sourceChapter: 1,
-            }),
-          ]),
-        );
-      } finally {
-        memoryDb.close();
-      }
+      const storyDir = join(state.bookDir(bookId), "story");
+      const chapterPath = join(state.bookDir(bookId), "chapters", "0001_Prelude.md");
+      const chapterFile = await readFile(chapterPath, "utf-8");
+      const chapterSummaries = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8");
+      const subplotBoard = await readFile(join(storyDir, "subplot_board.md"), "utf-8");
+
+      expect(foundation.mock.calls[0]?.[1]).toContain("Chapter 1: Prelude");
+      expect(foundation.mock.calls[0]?.[1]).not.toContain("第1章");
+      expect(saveChapter.mock.calls[0]?.[3]).toBe("en");
+      expect(chapterFile).toContain("# Chapter 1: Prelude");
+      expect(chapterSummaries).toContain("# Chapter Summaries");
+      expect(chapterSummaries).not.toContain("# 章节摘要");
+      expect(subplotBoard).toContain("# Subplot Board");
+      expect(subplotBoard).not.toContain("# 支线进度板");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("logs localized replay progress during chapter import", async () => {
+    const { logger, infos } = createCaptureLogger();
+    const { root, runner, bookId } = await createRunnerFixture({ logger });
+
+    vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
+      storyBible: "# Story Bible\n",
+      volumeOutline: "# Volume Outline\n",
+      bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
+      currentState: createStateCard({
+        chapter: 0,
+        location: "Ashen ferry crossing",
+        protagonistState: "Lin Yue still hides the oath token.",
+        goal: "Find the vanished mentor.",
+        conflict: "The mentor debt is still personal.",
+      }),
+      pendingHooks: "# Pending Hooks\n",
+    });
+    vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
+      createAnalyzedOutput({
+        chapterNumber: 1,
+        title: "Prelude",
+        content: "章节正文。",
+        wordCount: "章节正文。".length,
+      }),
+    );
+    vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
+    vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
+
+    try {
+      await runner.importChapters({
+        bookId,
+        chapters: [
+          { title: "第一章", content: "章节正文。" },
+        ],
+      });
+
+      expect(infos).toEqual(expect.arrayContaining([
+        "步骤 1:从 1 章生成基础设定...",
+        "基础设定已生成。",
+        "步骤 2:从第 1 章开始顺序回放...",
+        "分析章节 1/1:第一章...",
+        "完成。已导入 1 章,共 5字。下一章:2",
+      ]));
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("does not leak imported future state into early replay chapters", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const englishBook = {
+      ...(await state.loadBookConfig(bookId)),
+      genre: "other",
+      language: "en" as const,
+      chapterWordCount: 2200,
+    };
+
+    await state.saveBookConfig(bookId, englishBook);
+    await mkdir(join(storyDir, "snapshots", "0"), { recursive: true });
+    await Promise.all([
+      writeFile(join(storyDir, "subplot_board.md"), "# Subplot Board\n\nFUTURE LEAK subplot\n", "utf-8"),
+      writeFile(join(storyDir, "emotional_arcs.md"), "# Emotional Arcs\n\nFUTURE LEAK emotion\n", "utf-8"),
+      writeFile(join(storyDir, "character_matrix.md"), "# Character Matrix\n\nFUTURE LEAK matrix\n", "utf-8"),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "# Chapter Summaries",
+          "",
+          "| Chapter | Title | Characters | Key Events | State Changes | Hook Activity | Mood | Chapter Type |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 99 | Future | Future Cast | FUTURE LEAK event | FUTURE LEAK state | FUTURE LEAK hook | grim | finale |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "snapshots", "0", "current_state.md"),
+        createStateCard({
+          chapter: 60,
+          location: "Chengdu court",
+          protagonistState: "FUTURE LEAK snapshot",
+          goal: "Secure the western kingdom.",
+          conflict: "Late-book imperial rivalry is now fully active.",
+        }),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "snapshots", "0", "pending_hooks.md"),
+        [
+          "# Pending Hooks",
+          "",
+          "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| future-hook | 60 | mystery | open | 60 | Future payoff | FUTURE LEAK |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
+      storyBible: "# Story Bible\n",
+      volumeOutline: "# Volume Outline\n",
+      bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
+      currentState: createStateCard({
+        chapter: 60,
+        location: "Chengdu court",
+        protagonistState: "FUTURE LEAK: Liu Bei already holds Yizhou.",
+        goal: "Secure the western kingdom.",
+        conflict: "Late-book imperial rivalry is now fully active.",
+      }),
+      pendingHooks: [
+        "# Pending Hooks",
+        "",
+        "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
+        "| --- | --- | --- | --- | --- | --- | --- |",
+        "| future-hook | 60 | mystery | open | 60 | Future payoff | FUTURE LEAK |",
+        "",
+      ].join("\n"),
+    });
+
+    let stateSeenByFirstReplay = "";
+    let hooksSeenByFirstReplay = "";
+    let subplotSeenByFirstReplay = "";
+    let emotionalSeenByFirstReplay = "";
+    let matrixSeenByFirstReplay = "";
+    let summariesSeenByFirstReplay = "";
+
+    vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockImplementationOnce(async (input) => {
+      stateSeenByFirstReplay = await readFile(join(input.bookDir, "story", "current_state.md"), "utf-8");
+      hooksSeenByFirstReplay = await readFile(join(input.bookDir, "story", "pending_hooks.md"), "utf-8");
+      subplotSeenByFirstReplay = await readFile(join(input.bookDir, "story", "subplot_board.md"), "utf-8").catch(() => "");
+      emotionalSeenByFirstReplay = await readFile(join(input.bookDir, "story", "emotional_arcs.md"), "utf-8").catch(() => "");
+      matrixSeenByFirstReplay = await readFile(join(input.bookDir, "story", "character_matrix.md"), "utf-8").catch(() => "");
+      summariesSeenByFirstReplay = await readFile(join(input.bookDir, "story", "chapter_summaries.md"), "utf-8").catch(() => "");
+
+      return createAnalyzedOutput({
+        chapterNumber: 1,
+        title: "Prelude",
+        content: "A cold wind crossed the harbor.",
+        wordCount: countChapterLength("A cold wind crossed the harbor.", "en_words"),
+        updatedState: createStateCard({
+          chapter: 1,
+          location: "Harbor gate",
+          protagonistState: "Mara hides the sealed letter under her coat.",
+          goal: "Slip past the harbor watch.",
+          conflict: "The watch now searches for the missing captain's courier.",
+        }),
+        updatedHooks: [
+          "# Pending Hooks",
+          "",
+          "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| captain-letter | 1 | mystery | open | 1 | The captain's disappearance is explained. | The sealed letter points to the vanished captain. |",
+          "",
+        ].join("\n"),
+      });
+    });
+
+    try {
+      await runner.importChapters({
+        bookId,
+        chapters: [
+          { title: "Prelude", content: "A cold wind crossed the harbor." },
+        ],
+      });
+
+      expect(stateSeenByFirstReplay).toContain("| Current Chapter | 0 |");
+      expect(stateSeenByFirstReplay).not.toContain("FUTURE LEAK");
+      expect(hooksSeenByFirstReplay).toContain("# Pending Hooks");
+      expect(hooksSeenByFirstReplay).not.toContain("future-hook");
+      expect(hooksSeenByFirstReplay).not.toContain("FUTURE LEAK");
+      expect(subplotSeenByFirstReplay).not.toContain("FUTURE LEAK");
+      expect(emotionalSeenByFirstReplay).not.toContain("FUTURE LEAK");
+      expect(matrixSeenByFirstReplay).not.toContain("FUTURE LEAK");
+      expect(summariesSeenByFirstReplay).not.toContain("FUTURE LEAK");
+
+      const snapshotZeroState = await readFile(join(storyDir, "snapshots", "0", "current_state.md"), "utf-8");
+      const snapshotZeroHooks = await readFile(join(storyDir, "snapshots", "0", "pending_hooks.md"), "utf-8");
+      expect(snapshotZeroState).toContain("| Current Chapter | 0 |");
+      expect(snapshotZeroState).not.toContain("FUTURE LEAK");
+      expect(snapshotZeroHooks).toContain("# Pending Hooks");
+      expect(snapshotZeroHooks).not.toContain("future-hook");
+      expect(snapshotZeroHooks).not.toContain("FUTURE LEAK");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("rebuilds current facts from the revised chapter snapshot", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    const oldState = createStateCard({
+      chapter: 1,
+      location: "Ashen ferry crossing",
+      protagonistState: "Lin Yue still hides the oath token.",
+      goal: "Find the vanished mentor.",
+      conflict: "The mentor debt is still personal.",
+    });
+    const revisedState = createStateCard({
+      chapter: 1,
+      location: "Ashen ferry crossing",
+      protagonistState: "Lin Yue no longer hides the oath token.",
+      goal: "Confront the vanished mentor.",
+      conflict: "The oath token is public now, forcing the confrontation.",
+    });
+
+    await Promise.all([
+      writeFile(join(chaptersDir, "0001_Test_Chapter.md"), "# 第1章 Test Chapter\n\nOriginal body.", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), oldState, "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+    ]);
+    await state.saveChapterIndex(bookId, [{
+      number: 1,
+      title: "Test Chapter",
+      status: "audit-failed",
+      wordCount: "Original body.".length,
+      createdAt: "2026-03-19T00:00:00.000Z",
+      updatedAt: "2026-03-19T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    }]);
+    await state.snapshotState(bookId, 1);
+
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [CRITICAL_ISSUE],
+          summary: "needs revision",
+        }),
+      )
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: true,
+          issues: [],
+          summary: "clean",
+        }),
+      );
+    vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: "Revised body.",
+        wordCount: "Revised body.".length,
+        updatedState: revisedState,
+        updatedHooks: "# Pending Hooks\n",
+      }),
+    );
+
+    try {
+      await runner.reviseDraft(bookId, 1);
+
+      const memoryDb = new MemoryDB(state.bookDir(bookId));
+      try {
+        expect(memoryDb.getCurrentFacts()).toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({
+              predicate: "Current Conflict",
+              object: "The oath token is public now, forcing the confrontation.",
+              validFromChapter: 1,
+              sourceChapter: 1,
+            }),
+          ]),
+        );
+      } finally {
+        memoryDb.close();
+      }
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("feeds long-span fatigue warnings back into pipeline audit and drift correction", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const now = "2026-03-19T00:00:00.000Z";
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 2,
+        location: "Ashen ferry crossing",
+        protagonistState: "Lin Yue still hides the oath token.",
+        goal: "Find the vanished mentor.",
+        conflict: "The debt trail keeps narrowing.",
+      }), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "# 章节摘要",
+          "",
+          "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+          "|------|------|----------|----------|----------|----------|----------|----------|",
+          "| 1 | 旧路 | 林越 | 进城 | 潜伏开始 | 债印未解 | 克制 | 布局 |",
+          "| 2 | 暗巷 | 林越 | 试探 | 目标未变 | 债印未解 | 克制 | 布局 |",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(join(state.bookDir(bookId), "chapters", "0001_旧路.md"), "# 第1章 旧路\n\n城门在晨雾里半开。林越顺着石阶慢慢往里走。巷口那盏灯一直没有灭。", "utf-8"),
+      writeFile(join(state.bookDir(bookId), "chapters", "0002_暗巷.md"), "# 第2章 暗巷\n\n午后的风掠过墙头。林越没有回头,只是沿着阴影继续向前。墙后的铃声很轻。", "utf-8"),
+    ]);
+    await state.saveChapterIndex(bookId, [
+      {
+        number: 1,
+        title: "旧路",
+        status: "ready-for-review",
+        wordCount: 36,
+        createdAt: now,
+        updatedAt: now,
+        auditIssues: [],
+        lengthWarnings: [],
+      },
+      {
+        number: 2,
+        title: "暗巷",
+        status: "ready-for-review",
+        wordCount: 36,
+        createdAt: now,
+        updatedAt: now,
+        auditIssues: [],
+        lengthWarnings: [],
+      },
+    ]);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 3,
+        title: "回声",
+        content: "夜色慢慢压低了屋檐。林越先停在门外,随后才抬手去碰那道旧债印。风从更深的巷子里吹了出来。",
+        wordCount: "夜色慢慢压低了屋檐。林越先停在门外,随后才抬手去碰那道旧债印。风从更深的巷子里吹了出来。".length,
+        updatedState: createStateCard({
+          chapter: 3,
+          location: "Ashen ferry crossing",
+          protagonistState: "Lin Yue still hides the oath token.",
+          goal: "Find the vanished mentor.",
+          conflict: "The debt trail keeps narrowing.",
+        }),
+        updatedLedger: "",
+        updatedHooks: "# Pending Hooks\n",
+        chapterSummary: "| 3 | 回声 | 林越 | 继续潜伏 | 目标未变 | 债印未解 | 克制 | 布局 |",
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "ok",
+      }),
+    );
+
+    try {
+      const result = await runner.writeNextChapter(bookId);
+      const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8");
+
+      expect(result.auditResult.issues.some((issue) => issue.category === "节奏单调")).toBe(true);
+      expect(currentState).toContain("节奏单调");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("defaults manual reviseDraft to spot-fix when mode is omitted", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+
+    await Promise.all([
+      writeFile(join(chaptersDir, "0001_Test_Chapter.md"), "# 第1章 Test Chapter\n\nOriginal body.", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 1,
+        location: "Ashen ferry crossing",
+        protagonistState: "Lin Yue still hides the oath token.",
+        goal: "Find the vanished mentor.",
+        conflict: "The mentor debt is still personal.",
+      }), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+    ]);
+    await state.saveChapterIndex(bookId, [{
+      number: 1,
+      title: "Test Chapter",
+      status: "audit-failed",
+      wordCount: "Original body.".length,
+      createdAt: "2026-03-19T00:00:00.000Z",
+      updatedAt: "2026-03-19T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    }]);
+
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: false,
+        issues: [CRITICAL_ISSUE],
+        summary: "needs revision",
+      }),
+    );
+    const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: "Spot-fixed body.",
+        wordCount: "Spot-fixed body.".length,
+        updatedState: createStateCard({
+          chapter: 1,
+          location: "Ashen ferry crossing",
+          protagonistState: "Lin Yue still hides the oath token.",
+          goal: "Find the vanished mentor.",
+          conflict: "The mentor debt is repaired.",
+        }),
+        updatedHooks: "# Pending Hooks\n",
+      }),
+    );
+
+    try {
+      await runner.reviseDraft(bookId, 1);
+
+      expect(reviseChapter).toHaveBeenCalledTimes(1);
+      expect(reviseChapter.mock.calls[0]?.[4]).toBe("spot-fix");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("passes governed control inputs into manual revise in v2 mode", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "v2",
+    });
+    const storyDir = join(state.bookDir(bookId), "story");
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    const originalBody = "林越推门进去,先看见柜台后那盏没关的灯。";
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_focus.md"), "# 当前聚焦\n\n## 当前重点\n\n把注意力收回师债主线。\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# 卷纲\n\n## 第1章\n先处理商会路线噪音。\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 1,
+        location: "旧港便利店",
+        protagonistState: "林越仍在追查师债。",
+        goal: "把注意力拉回师债线索。",
+        conflict: "商会路线仍在分散注意力。",
+      }), "utf-8"),
+      writeFile(join(storyDir, "story_bible.md"), "# 世界观设定\n\n- 誓令碎片不可伪造。\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# 伏笔池\n\n- 师债线索仍未回收。\n", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), [
+        "# 章节摘要",
+        "",
+        "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+        "| --- | --- | --- | --- | --- | --- | --- | --- |",
+        "| 1 | 夜灯 | 林越 | 林越继续追查师债 | 追查意图更强 | 师债推进 | 压抑 | 主线推进 |",
+        "",
+      ].join("\n"), "utf-8"),
+      writeFile(join(chaptersDir, "0001_夜灯.md"), `# 第1章 夜灯\n\n${originalBody}`, "utf-8"),
+    ]);
+    await state.saveChapterIndex(bookId, [{
+      number: 1,
+      title: "夜灯",
+      status: "audit-failed",
+      wordCount: originalBody.length,
+      createdAt: "2026-03-19T00:00:00.000Z",
+      updatedAt: "2026-03-19T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    }]);
+
+    const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [CRITICAL_ISSUE],
+          summary: "needs revision",
+        }),
+      )
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: true,
+          issues: [],
+          summary: "clean",
+        }),
+      );
+    const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
+        wordCount: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。".length,
+        fixedIssues: ["- 收紧了主线焦点。"],
+        updatedState: createStateCard({
+          chapter: 1,
+          location: "旧港便利店",
+          protagonistState: "林越把注意力重新拉回师债。",
+          goal: "继续追查师债。",
+          conflict: "商会路线暂时退居背景。",
+        }),
+        updatedHooks: "# 伏笔池\n\n- 师债线索仍未回收。\n",
+      }),
+    );
+
+    try {
+      await runner.reviseDraft(bookId, 1);
+
+      expect(auditChapter.mock.calls[0]?.[4]).toMatchObject({
+        chapterIntent: expect.stringContaining("# Chapter Intent"),
+        contextPackage: expect.objectContaining({
+          selectedContext: expect.any(Array),
+        }),
+        ruleStack: expect.objectContaining({
+          activeOverrides: expect.any(Array),
+        }),
+      });
+      expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
+        chapterIntent: expect.stringContaining("# Chapter Intent"),
+        contextPackage: expect.objectContaining({
+          selectedContext: expect.any(Array),
+        }),
+        ruleStack: expect.objectContaining({
+          activeOverrides: expect.any(Array),
+        }),
+        lengthSpec: expect.objectContaining({
+          target: 3000,
+        }),
+      });
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("passes merged AI-tell issues into manual revise and rejects no-improvement revisions", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    const originalBody = "林越抬手。林越停步。林越转身。林越侧耳。";
+
+    await Promise.all([
+      writeFile(join(chaptersDir, "0001_Test_Chapter.md"), `# 第1章 Test Chapter\n\n${originalBody}`, "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 1,
+        location: "Ashen ferry crossing",
+        protagonistState: "Lin Yue still hides the oath token.",
+        goal: "Find the vanished mentor.",
+        conflict: "The mentor debt is still personal.",
+      }), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+    ]);
+    await state.saveChapterIndex(bookId, [{
+      number: 1,
+      title: "Test Chapter",
+      status: "audit-failed",
+      wordCount: originalBody.length,
+      createdAt: "2026-03-19T00:00:00.000Z",
+      updatedAt: "2026-03-19T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    }]);
+
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [{
+            severity: "warning",
+            category: "节奏",
+            description: "结尾解释略多。",
+            suggestion: "压缩一行解释。",
+          }],
+          summary: "needs revision",
+        }),
+      )
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [{
+            severity: "warning",
+            category: "节奏",
+            description: "结尾解释略多。",
+            suggestion: "压缩一行解释。",
+          }],
+          summary: "still weak",
+        }),
+      );
+    const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: `${originalBody}\n\n修订后收束更利落。`,
+        wordCount: `${originalBody}\n\n修订后收束更利落。`.length,
+        fixedIssues: ["- 压缩了结尾解释。"],
+      }),
+    );
+
+    try {
+      const result = await runner.reviseDraft(bookId, 1);
+      const savedChapter = await readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8");
+      const savedIndex = await state.loadChapterIndex(bookId);
+
+      expect(reviseChapter).toHaveBeenCalledTimes(1);
+      expect(reviseChapter.mock.calls[0]?.[3]).toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({ category: "节奏" }),
+          expect.objectContaining({ category: "列表式结构" }),
+        ]),
+      );
+      expect(result.applied).toBe(false);
+      expect(result.status).toBe("unchanged");
+      expect(result.skippedReason).toContain("did not improve");
+      expect(savedChapter).toContain(originalBody);
+      expect(savedChapter).not.toContain("修订后收束更利落");
+      expect(savedIndex[0]?.status).toBe("audit-failed");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("persists manual revisions only when merged audit improves", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    const originalBody = "林越抬手。林越停步。林越转身。林越侧耳。";
+    const revisedBody = "门被风顶开,林越先停在门槛前。\n\n他侧过身,听见墙后那道更轻的呼吸。";
+
+    await Promise.all([
+      writeFile(join(chaptersDir, "0001_Test_Chapter.md"), `# 第1章 Test Chapter\n\n${originalBody}`, "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 1,
+        location: "Ashen ferry crossing",
+        protagonistState: "Lin Yue still hides the oath token.",
+        goal: "Find the vanished mentor.",
+        conflict: "The mentor debt is still personal.",
+      }), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+    ]);
+    await state.saveChapterIndex(bookId, [{
+      number: 1,
+      title: "Test Chapter",
+      status: "audit-failed",
+      wordCount: originalBody.length,
+      createdAt: "2026-03-19T00:00:00.000Z",
+      updatedAt: "2026-03-19T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    }]);
+
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [{
+            severity: "warning",
+            category: "节奏",
+            description: "结尾解释略多。",
+            suggestion: "压缩一行解释。",
+          }],
+          summary: "needs revision",
+        }),
+      )
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: true,
+          issues: [],
+          summary: "clean",
+        }),
+      );
+    vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: revisedBody,
+        wordCount: revisedBody.length,
+        fixedIssues: ["- 收紧了结尾节奏。"],
+        updatedState: createStateCard({
+          chapter: 1,
+          location: "Ashen ferry crossing",
+          protagonistState: "Lin Yue still hides the oath token.",
+          goal: "Find the vanished mentor.",
+          conflict: "The mentor debt sharpens into a direct threat.",
+        }),
+        updatedHooks: "# Pending Hooks\n",
+      }),
+    );
+
+    try {
+      const result = await runner.reviseDraft(bookId, 1);
+      const savedChapter = await readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8");
+      const savedIndex = await state.loadChapterIndex(bookId);
+
+      expect(result.applied).toBe(true);
+      expect(result.status).toBe("ready-for-review");
+      expect(result.fixedIssues).toEqual(["- 收紧了结尾节奏。"]);
+      expect(savedChapter).toContain(revisedBody);
+      expect(savedIndex[0]?.status).toBe("ready-for-review");
+      expect(savedIndex[0]?.auditIssues).toEqual([]);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("uses chapter length telemetry target for manual revise when available", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    const originalBody = "Tarin waited by the crooked berth marker and counted the missing lines twice.";
+    const revisedBody = `${originalBody}\n\nHe did not move until the second bell rang across the water.`;
+
+    await state.saveBookConfig(bookId, {
+      ...(await state.loadBookConfig(bookId)),
+      platform: "other",
+      genre: "progression",
+      language: "en",
+      chapterWordCount: 1800,
+    });
+
+    await Promise.all([
+      writeFile(join(chaptersDir, "0001_Test_Chapter.md"), `# Chapter 1: Test Chapter\n\n${originalBody}`, "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 1,
+        location: "Dock Nine",
+        protagonistState: "Tarin still carries the sealed packet.",
+        goal: "Find Captain Voss.",
+        conflict: "The berth is wrong and the crew is missing.",
+      }), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+    ]);
+    await state.saveChapterIndex(bookId, [{
+      number: 1,
+      title: "Test Chapter",
+      status: "audit-failed",
+      wordCount: countChapterLength(originalBody, "en_words"),
+      createdAt: "2026-03-19T00:00:00.000Z",
+      updatedAt: "2026-03-19T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+      lengthTelemetry: {
+        target: 900,
+        softMin: 778,
+        softMax: 1022,
+        hardMin: 655,
+        hardMax: 1145,
+        countingMode: "en_words",
+        writerCount: countChapterLength(originalBody, "en_words"),
+        postWriterNormalizeCount: countChapterLength(originalBody, "en_words"),
+        postReviseCount: 0,
+        finalCount: countChapterLength(originalBody, "en_words"),
+        normalizeApplied: false,
+        lengthWarning: false,
+      },
+    }]);
+
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [CRITICAL_ISSUE],
+          summary: "needs revision",
+        }),
+      )
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: true,
+          issues: [],
+          summary: "clean",
+        }),
+      );
+
+    const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: revisedBody,
+        wordCount: countChapterLength(revisedBody, "en_words"),
+        fixedIssues: ["- Tightened the berth discovery beat."],
+        updatedState: createStateCard({
+          chapter: 1,
+          location: "Dock Nine",
+          protagonistState: "Tarin still carries the sealed packet.",
+          goal: "Find Captain Voss.",
+          conflict: "The berth is wrong and the crew is missing.",
+        }),
+        updatedHooks: "# Pending Hooks\n",
+      }),
+    );
+
+    try {
+      await runner.reviseDraft(bookId, 1, "polish");
+
+      expect(reviseChapter).toHaveBeenCalledTimes(1);
+      expect(reviseChapter.mock.calls[0]?.[6]?.lengthSpec).toMatchObject({
+        target: 900,
+        countingMode: "en_words",
+      });
     } finally {
     } finally {
       await rm(root, { recursive: true, force: true });
       await rm(root, { recursive: true, force: true });
     }
     }

+ 206 - 0
packages/core/src/__tests__/planner.test.ts

@@ -95,6 +95,95 @@ describe("PlannerAgent", () => {
     await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("mentor conflict");
     await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("mentor conflict");
   });
   });
 
 
+  it("ignores the default current_focus placeholder and falls back to author intent when no chapter outline is available", async () => {
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_focus.md"),
+        [
+          "# Current Focus",
+          "",
+          "## Active Focus",
+          "",
+          "(Describe what the next 1-3 chapters should prioritize.)",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "volume_outline.md"),
+        "# Volume Outline\n",
+        "utf-8",
+      ),
+    ]);
+
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 3,
+    });
+
+    expect(result.intent.goal).toContain("mentor-student bond");
+    expect(result.intent.goal).not.toContain("Describe what the next 1-3 chapters should prioritize");
+  });
+
+  it("uses bullet-style volume outline chapter nodes as the fallback goal when control docs are placeholders", async () => {
+    await Promise.all([
+      writeFile(
+        join(storyDir, "author_intent.md"),
+        "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "current_focus.md"),
+        [
+          "# Current Focus",
+          "",
+          "## Active Focus",
+          "",
+          "(Describe what the next 1-3 chapters should prioritize.)",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "volume_outline.md"),
+        [
+          "## Volume 1",
+          "**Chapter range:** 1-8",
+          "",
+          "**Key turning points:**",
+          "- **Chapter 3:** Track the merchant guild's escape route through the western canal.",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 3,
+    });
+
+    expect(result.intent.outlineNode).toContain("merchant guild's escape route");
+    expect(result.intent.goal).toContain("merchant guild's escape route");
+    expect(result.intent.goal).not.toContain("Advance chapter 3 with clear narrative focus.");
+  });
+
   it("preserves hard facts from state and canon in mustKeep", async () => {
   it("preserves hard facts from state and canon in mustKeep", async () => {
     const planner = new PlannerAgent({
     const planner = new PlannerAgent({
       client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
       client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
@@ -190,4 +279,121 @@ describe("PlannerAgent", () => {
     expect(intentMarkdown).not.toContain("| 1 | Guild Trail |");
     expect(intentMarkdown).not.toContain("| 1 | Guild Trail |");
     expect(intentMarkdown).not.toContain("| old-seal | 3 | artifact | resolved |");
     expect(intentMarkdown).not.toContain("| old-seal | 3 | artifact | resolved |");
   });
   });
+
+  it("renders English memory snapshot headers for English books", async () => {
+    book = {
+      ...book,
+      genre: "other",
+      language: "en",
+    };
+    await Promise.all([
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "# Pending Hooks",
+          "",
+          "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| mentor-oath | 8 | relationship | open | 9 | 11 | Mentor oath debt with Lin Yue |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "# Chapter Summaries",
+          "",
+          "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 10 | Locked Gate | Lin Yue | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | mentor-oath advanced | focused | decision |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 11,
+      externalContext: "Bring the focus back to the mentor oath conflict with Lin Yue.",
+    });
+
+    const intentMarkdown = await readFile(result.runtimePath, "utf-8");
+    expect(intentMarkdown).toContain("| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |");
+    expect(intentMarkdown).toContain("| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |");
+    expect(intentMarkdown).not.toContain("| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |");
+    expect(intentMarkdown).not.toContain("| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |");
+  });
+
+  it("derives structured current_focus markdown into goal, avoids, and style emphasis", async () => {
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_focus.md"),
+        [
+          "# Current Focus",
+          "",
+          "## Active Focus",
+          "",
+          "- Bring the focus back to Lin Yue's private confrontation with the mentor debt.",
+          "- Keep the chapter centered on a missing record, not a whole-conspiracy overview.",
+          "- Surface one concrete evidence trail the next chapter can pursue.",
+          "",
+          "## Avoid",
+          "",
+          "- Do not turn this chapter into a citywide survey of every faction.",
+          "- Do not use summary-heavy moralizing paragraphs.",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "story_bible.md"),
+        [
+          "# Story Bible",
+          "",
+          "- --",
+          "- The jade seal cannot be destroyed.",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 3,
+    });
+
+    expect(result.intent.goal).toContain("private confrontation");
+    expect(result.intent.goal).toContain("missing record");
+    expect(result.intent.mustAvoid).toEqual(expect.arrayContaining([
+      "Do not turn this chapter into a citywide survey of every faction.",
+      "Do not use summary-heavy moralizing paragraphs.",
+    ]));
+    expect(result.intent.mustAvoid).not.toContain(
+      "Keep the chapter centered on a missing record, not a whole-conspiracy overview.",
+    );
+    expect(result.intent.styleEmphasis).toEqual(expect.arrayContaining([
+      "Bring the focus back to Lin Yue's private confrontation with the mentor debt.",
+      "Surface one concrete evidence trail the next chapter can pursue.",
+    ]));
+    expect(result.intent.mustKeep).not.toContain("--");
+  });
 });
 });

+ 14 - 0
packages/core/src/__tests__/post-write-validator.test.ts

@@ -41,6 +41,20 @@ describe("validatePostWrite", () => {
     expect(findRule(result, "禁止破折号")!.severity).toBe("error");
     expect(findRule(result, "禁止破折号")!.severity).toBe("error");
   });
   });
 
 
+  it("skips Chinese-only rules when the book language override is English", () => {
+    const content = "He stepped forward——then stopped at the door.";
+    const validateWithLanguage = validatePostWrite as (
+      content: string,
+      genreProfile: GenreProfile,
+      bookRules: null,
+      languageOverride?: "zh" | "en",
+    ) => ReadonlyArray<PostWriteViolation>;
+
+    const result = validateWithLanguage(content, baseProfile, null, "en");
+
+    expect(findRule(result, "禁止破折号")).toBeUndefined();
+  });
+
   it("detects surprise marker density exceeding threshold", () => {
   it("detects surprise marker density exceeding threshold", () => {
     // ~100 chars total, threshold = max(1, floor(100/3000)) = 1, but we put 3 markers
     // ~100 chars total, threshold = max(1, floor(100/3000)) = 1, but we put 3 markers
     const content = "他忽然站起来。仿佛听到了什么声音。竟然是那个人回来了。";
     const content = "他忽然站起来。仿佛听到了什么声音。竟然是那个人回来了。";

+ 259 - 6
packages/core/src/__tests__/reviser.test.ts

@@ -24,6 +24,134 @@ describe("ReviserAgent", () => {
     vi.restoreAllMocks();
     vi.restoreAllMocks();
   });
   });
 
 
+  it("prefers book language override when building revision prompts", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-reviser-lang-test-"));
+    const bookDir = join(root, "book");
+    await mkdir(join(bookDir, "story"), { recursive: true });
+
+    await writeFile(
+      join(bookDir, "book.json"),
+      JSON.stringify({
+        id: "english-book",
+        title: "English Book",
+        genre: "xuanhuan",
+        platform: "royalroad",
+        chapterWordCount: 800,
+        targetChapters: 60,
+        status: "active",
+        language: "en",
+        createdAt: "2026-03-23T00:00:00.000Z",
+        updatedAt: "2026-03-23T00:00:00.000Z",
+      }, null, 2),
+      "utf-8",
+    );
+
+    const agent = new ReviserAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const chatSpy = vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: [
+        "=== FIXED_ISSUES ===",
+        "- repaired",
+        "",
+        "=== REVISED_CONTENT ===",
+        "Revised chapter content.",
+        "",
+        "=== UPDATED_STATE ===",
+        "State card",
+        "",
+        "=== UPDATED_HOOKS ===",
+        "Hooks board",
+      ].join("\n"),
+      usage: ZERO_USAGE,
+    });
+
+    try {
+      await agent.reviseChapter(bookDir, "Original chapter content.", 1, [CRITICAL_ISSUE], "rewrite", "xuanhuan");
+
+      const messages = chatSpy.mock.calls[0]?.[0] as
+        | ReadonlyArray<{ content: string }>
+        | undefined;
+      const systemPrompt = messages?.[0]?.content ?? "";
+
+      expect(systemPrompt).toContain("MUST be in English");
+      expect(systemPrompt).toContain("written entirely in English");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("keeps rewrite mode local-first instead of encouraging full-chapter replacement", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-reviser-rewrite-guardrail-test-"));
+    const bookDir = join(root, "book");
+    await mkdir(join(bookDir, "story"), { recursive: true });
+
+    const agent = new ReviserAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const chatSpy = vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: [
+        "=== FIXED_ISSUES ===",
+        "- repaired",
+        "",
+        "=== PATCHES ===",
+        "--- PATCH 1 ---",
+        "TARGET_TEXT:",
+        "原始正文。",
+        "REPLACEMENT_TEXT:",
+        "修订后的正文。",
+        "--- END PATCH ---",
+        "",
+        "=== UPDATED_STATE ===",
+        "状态卡",
+        "",
+        "=== UPDATED_HOOKS ===",
+        "伏笔池",
+      ].join("\n"),
+      usage: ZERO_USAGE,
+    });
+
+    try {
+      await agent.reviseChapter(bookDir, "原始正文。", 1, [CRITICAL_ISSUE], "rewrite", "xuanhuan");
+
+      const messages = chatSpy.mock.calls[0]?.[0] as
+        | ReadonlyArray<{ content: string }>
+        | undefined;
+      const systemPrompt = messages?.[0]?.content ?? "";
+
+      expect(systemPrompt).toContain("优先保留原文的绝大部分句段");
+      expect(systemPrompt).toContain("除非问题跨越整章");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("tells the model to preserve the target range when a length spec is provided", async () => {
   it("tells the model to preserve the target range when a length spec is provided", async () => {
     const root = await mkdtemp(join(tmpdir(), "inkos-reviser-test-"));
     const root = await mkdtemp(join(tmpdir(), "inkos-reviser-test-"));
     const bookDir = join(root, "book");
     const bookDir = join(root, "book");
@@ -37,7 +165,7 @@ describe("ReviserAgent", () => {
         defaults: {
         defaults: {
           temperature: 0.7,
           temperature: 0.7,
           maxTokens: 4096,
           maxTokens: 4096,
-          thinkingBudget: 0,
+          thinkingBudget: 0, maxTokensCap: null,
           extra: {},
           extra: {},
         },
         },
       },
       },
@@ -50,8 +178,13 @@ describe("ReviserAgent", () => {
         "=== FIXED_ISSUES ===",
         "=== FIXED_ISSUES ===",
         "- repaired",
         "- repaired",
         "",
         "",
-        "=== REVISED_CONTENT ===",
+        "=== PATCHES ===",
+        "--- PATCH 1 ---",
+        "TARGET_TEXT:",
+        "原始正文。",
+        "REPLACEMENT_TEXT:",
         "修订后的正文。",
         "修订后的正文。",
+        "--- END PATCH ---",
         "",
         "",
         "=== UPDATED_STATE ===",
         "=== UPDATED_STATE ===",
         "状态卡",
         "状态卡",
@@ -82,6 +215,8 @@ describe("ReviserAgent", () => {
       const userPrompt = messages?.[1]?.content ?? "";
       const userPrompt = messages?.[1]?.content ?? "";
 
 
       expect(systemPrompt).toContain("保持章节字数在目标区间内");
       expect(systemPrompt).toContain("保持章节字数在目标区间内");
+      expect(systemPrompt).toContain("=== PATCHES ===");
+      expect(systemPrompt).not.toContain("=== REVISED_CONTENT ===");
       expect(userPrompt).toContain("目标字数:220");
       expect(userPrompt).toContain("目标字数:220");
       expect(userPrompt).toContain("允许区间:190-250");
       expect(userPrompt).toContain("允许区间:190-250");
     } finally {
     } finally {
@@ -89,6 +224,82 @@ describe("ReviserAgent", () => {
     }
     }
   });
   });
 
 
+  it("reconstructs revised content from spot-fix patches and preserves untouched text", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-reviser-spotfix-patch-test-"));
+    const bookDir = join(root, "book");
+    await mkdir(join(bookDir, "story"), { recursive: true });
+
+    const agent = new ReviserAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: [
+        "=== FIXED_ISSUES ===",
+        "- 收紧了开头动作句。",
+        "",
+        "=== PATCHES ===",
+        "--- PATCH 1 ---",
+        "TARGET_TEXT:",
+        "林越没有立刻进去。",
+        "REPLACEMENT_TEXT:",
+        "林越先停在门槛外,侧耳听了一息。",
+        "--- END PATCH ---",
+        "",
+        "=== UPDATED_STATE ===",
+        "状态卡",
+        "",
+        "=== UPDATED_HOOKS ===",
+        "伏笔池",
+      ].join("\n"),
+      usage: ZERO_USAGE,
+    });
+
+    const original = [
+      "门轴轻轻响了一下。",
+      "林越没有立刻进去。",
+      "",
+      "巷子尽头的风还在吹。",
+      "他把手按在潮冷的门框上,没有出声。",
+      "更远处传来极轻的脚步回响,又很快断掉。",
+    ].join("\n");
+
+    try {
+      const result = await agent.reviseChapter(
+        bookDir,
+        original,
+        1,
+        [CRITICAL_ISSUE],
+        "spot-fix",
+        "xuanhuan",
+      );
+
+      expect(result.revisedContent).toBe([
+        "门轴轻轻响了一下。",
+        "林越先停在门槛外,侧耳听了一息。",
+        "",
+        "巷子尽头的风还在吹。",
+        "他把手按在潮冷的门框上,没有出声。",
+        "更远处传来极轻的脚步回响,又很快断掉。",
+      ].join("\n"));
+      expect(result.fixedIssues).toEqual(["- 收紧了开头动作句。"]);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("uses selected summary and hook evidence instead of full long-history markdown in governed mode", async () => {
   it("uses selected summary and hook evidence instead of full long-history markdown in governed mode", async () => {
     const root = await mkdtemp(join(tmpdir(), "inkos-reviser-governed-test-"));
     const root = await mkdtemp(join(tmpdir(), "inkos-reviser-governed-test-"));
     const bookDir = join(root, "book");
     const bookDir = join(root, "book");
@@ -122,8 +333,31 @@ describe("ReviserAgent", () => {
         "utf-8",
         "utf-8",
       ),
       ),
       writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nTrack the merchant guild trail.\n", "utf-8"),
       writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nTrack the merchant guild trail.\n", "utf-8"),
-      writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
-      writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
+      writeFile(
+        join(storyDir, "story_bible.md"),
+        [
+          "# Story Bible",
+          "",
+          "- The jade seal cannot be destroyed.",
+          "- Guildmaster Ren secretly forged the harbor roster in chapter 140.",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "character_matrix.md"),
+        [
+          "# 角色交互矩阵",
+          "",
+          "### 角色档案",
+          "| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| Lin Yue | oath | restraint | clipped | stubborn | self | repay debt | find mentor |",
+          "| Guildmaster Ren | guild | swagger | loud | opportunistic | rival | stall Mara | seize seal |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
       writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
       writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
     ]);
     ]);
 
 
@@ -135,7 +369,7 @@ describe("ReviserAgent", () => {
         defaults: {
         defaults: {
           temperature: 0.7,
           temperature: 0.7,
           maxTokens: 4096,
           maxTokens: 4096,
-          thinkingBudget: 0,
+          thinkingBudget: 0, maxTokensCap: null,
           extra: {},
           extra: {},
         },
         },
       },
       },
@@ -148,8 +382,13 @@ describe("ReviserAgent", () => {
         "=== FIXED_ISSUES ===",
         "=== FIXED_ISSUES ===",
         "- repaired",
         "- repaired",
         "",
         "",
-        "=== REVISED_CONTENT ===",
+        "=== PATCHES ===",
+        "--- PATCH 1 ---",
+        "TARGET_TEXT:",
+        "原始正文。",
+        "REPLACEMENT_TEXT:",
         "修订后的正文。",
         "修订后的正文。",
+        "--- END PATCH ---",
         "",
         "",
         "=== UPDATED_STATE ===",
         "=== UPDATED_STATE ===",
         "状态卡",
         "状态卡",
@@ -173,6 +412,16 @@ describe("ReviserAgent", () => {
           contextPackage: {
           contextPackage: {
             chapter: 100,
             chapter: 100,
             selectedContext: [
             selectedContext: [
+              {
+                source: "story/story_bible.md",
+                reason: "Preserve canon constraints referenced by mustKeep.",
+                excerpt: "The jade seal cannot be destroyed.",
+              },
+              {
+                source: "story/volume_outline.md",
+                reason: "Anchor the default planning node for this chapter.",
+                excerpt: "Track the mentor oath fallout.",
+              },
               {
               {
                 source: "story/chapter_summaries.md#99",
                 source: "story/chapter_summaries.md#99",
                 reason: "Relevant episodic memory.",
                 reason: "Relevant episodic memory.",
@@ -206,8 +455,12 @@ describe("ReviserAgent", () => {
 
 
       expect(userPrompt).toContain("story/chapter_summaries.md#99");
       expect(userPrompt).toContain("story/chapter_summaries.md#99");
       expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath");
       expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath");
+      expect(userPrompt).toContain("story/story_bible.md");
+      expect(userPrompt).toContain("story/volume_outline.md");
       expect(userPrompt).not.toContain("| 1 | Guild Trail |");
       expect(userPrompt).not.toContain("| 1 | Guild Trail |");
       expect(userPrompt).not.toContain("guild-route | 1 | mystery");
       expect(userPrompt).not.toContain("guild-route | 1 | mystery");
+      expect(userPrompt).not.toContain("Guildmaster Ren secretly forged the harbor roster in chapter 140.");
+      expect(userPrompt).not.toContain("| Guildmaster Ren | guild | swagger | loud | opportunistic | rival | stall Mara | seize seal |");
     } finally {
     } finally {
       await rm(root, { recursive: true, force: true });
       await rm(root, { recursive: true, force: true });
     }
     }

+ 1 - 1
packages/core/src/__tests__/scheduler.test.ts

@@ -10,7 +10,7 @@ function createConfig(): SchedulerConfig {
       defaults: {
       defaults: {
         temperature: 0.7,
         temperature: 0.7,
         maxTokens: 1024,
         maxTokens: 1024,
-        thinkingBudget: 0,
+        thinkingBudget: 0, maxTokensCap: null,
       },
       },
     } as SchedulerConfig["client"],
     } as SchedulerConfig["client"],
     model: "test-model",
     model: "test-model",

+ 59 - 1
packages/core/src/__tests__/state-manager.test.ts

@@ -1,4 +1,4 @@
-import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
 import { mkdtemp, rm, writeFile, readFile, mkdir, stat } from "node:fs/promises";
 import { mkdtemp, rm, writeFile, readFile, mkdir, stat } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { join } from "node:path";
@@ -372,6 +372,33 @@ describe("StateManager", () => {
       expect(rejected).toHaveLength(1);
       expect(rejected).toHaveLength(1);
       expect(String(rejected[0]?.reason)).toMatch(/is locked/);
       expect(String(rejected[0]?.reason)).toMatch(/is locked/);
     });
     });
+
+    it("reclaims a stale lock when the recorded pid is no longer alive", async () => {
+      await mkdir(manager.bookDir("lock-book-5"), { recursive: true });
+      const lockPath = join(manager.bookDir("lock-book-5"), ".write.lock");
+      await writeFile(lockPath, "pid:424242 ts:123", "utf-8");
+
+      const killSpy = vi.spyOn(process, "kill").mockImplementation((((pid: number) => {
+        if (pid === 424242) {
+          const error = new Error("no such process") as NodeJS.ErrnoException;
+          error.code = "ESRCH";
+          throw error;
+        }
+        return true;
+      }) as unknown) as typeof process.kill);
+
+      try {
+        const release = await manager.acquireBookLock("lock-book-5");
+        const lockData = await readFile(lockPath, "utf-8");
+
+        expect(typeof release).toBe("function");
+        expect(lockData).toContain(`pid:${process.pid}`);
+
+        await release();
+      } finally {
+        killSpy.mockRestore();
+      }
+    });
   });
   });
 
 
   // -------------------------------------------------------------------------
   // -------------------------------------------------------------------------
@@ -432,5 +459,36 @@ describe("StateManager", () => {
       expect(controlDocs.currentFocus).toContain("# Current Focus");
       expect(controlDocs.currentFocus).toContain("# Current Focus");
       expect(controlDocs.runtimeDir).toBe(join(storyDir, "runtime"));
       expect(controlDocs.runtimeDir).toBe(join(storyDir, "runtime"));
     });
     });
+
+    it("creates localized Chinese defaults for Chinese books", async () => {
+      await manager.saveBookConfig("zh-book", {
+        id: "zh-book",
+        title: "中文书",
+        platform: "tomato",
+        genre: "other",
+        status: "outlining",
+        targetChapters: 100,
+        chapterWordCount: 2200,
+        language: "zh",
+        createdAt: "2026-03-24T00:00:00Z",
+        updatedAt: "2026-03-24T00:00:00Z",
+      });
+
+      await manager.ensureControlDocuments("zh-book");
+
+      const storyDir = join(manager.bookDir("zh-book"), "story");
+      const authorIntent = await readFile(
+        join(storyDir, "author_intent.md"),
+        "utf-8",
+      );
+      const currentFocus = await readFile(
+        join(storyDir, "current_focus.md"),
+        "utf-8",
+      );
+
+      expect(authorIntent).toContain("# 作者意图");
+      expect(currentFocus).toContain("# 当前聚焦");
+      expect(currentFocus).not.toContain("# Current Focus");
+    });
   });
   });
 });
 });

+ 42 - 0
packages/core/src/__tests__/writer-parser.test.ts

@@ -103,6 +103,16 @@ describe("WriterAgent parseOutput", () => {
     expect(result.title).toBe("第42章");
     expect(result.title).toBe("第42章");
   });
   });
 
 
+  it("returns an English default title when CHAPTER_TITLE is missing in English mode", () => {
+    const output = [
+      "=== CHAPTER_CONTENT ===",
+      "Some content here.",
+    ].join("\n");
+
+    const result = callParseOutput(42, output, defaultGenreProfile, "en_words");
+    expect(result.title).toBe("Chapter 42");
+  });
+
   it("returns empty content when CHAPTER_CONTENT is missing", () => {
   it("returns empty content when CHAPTER_CONTENT is missing", () => {
     const output = [
     const output = [
       "=== CHAPTER_TITLE ===",
       "=== CHAPTER_TITLE ===",
@@ -129,6 +139,21 @@ describe("WriterAgent parseOutput", () => {
     expect(result.updatedHooks).toBe("(伏笔池未更新)");
     expect(result.updatedHooks).toBe("(伏笔池未更新)");
   });
   });
 
 
+  it("returns English fallback strings for missing state sections in English mode", () => {
+    const output = [
+      "=== CHAPTER_TITLE ===",
+      "Title",
+      "",
+      "=== CHAPTER_CONTENT ===",
+      "Content.",
+    ].join("\n");
+
+    const result = callParseOutput(1, output, defaultGenreProfile, "en_words");
+    expect(result.updatedState).toBe("(state card not updated)");
+    expect(result.updatedLedger).toBe("(ledger not updated)");
+    expect(result.updatedHooks).toBe("(hooks pool not updated)");
+  });
+
   it("returns empty string for missing PRE_WRITE_CHECK", () => {
   it("returns empty string for missing PRE_WRITE_CHECK", () => {
     const output = [
     const output = [
       "=== CHAPTER_TITLE ===",
       "=== CHAPTER_TITLE ===",
@@ -250,6 +275,17 @@ describe("parseCreativeOutput fallback", () => {
     expect(result.content).toContain("林风");
     expect(result.content).toContain("林风");
   });
   });
 
 
+  it("extracts English content from markdown headings when tags are missing", () => {
+    const raw = `# Chapter 1: Awakening Day
+
+He woke to the sound of distant bells and the taste of salt in the air. ${"Long English prose follows. ".repeat(15)}`;
+
+    const result = parseCreativeOutput(1, raw, "en_words");
+    expect(result.title).toBe("Awakening Day");
+    expect(result.content.length).toBeGreaterThan(100);
+    expect(result.content).toContain("distant bells");
+  });
+
   it("extracts content from 正文 label when tags are missing", () => {
   it("extracts content from 正文 label when tags are missing", () => {
     const raw = `章节标题:暗夜追踪
     const raw = `章节标题:暗夜追踪
 
 
@@ -278,6 +314,12 @@ ${prose}`;
     expect(result.title).toBe("第1章");
     expect(result.title).toBe("第1章");
   });
   });
 
 
+  it("returns an English fallback title when short English output has no structure", () => {
+    const result = parseCreativeOutput(1, "too short", "en_words");
+    expect(result.content).toBe("");
+    expect(result.title).toBe("Chapter 1");
+  });
+
   it("still works with proper === TAG === format", () => {
   it("still works with proper === TAG === format", () => {
     const raw = `=== PRE_WRITE_CHECK ===
     const raw = `=== PRE_WRITE_CHECK ===
 自检完成
 自检完成

+ 167 - 4
packages/core/src/__tests__/writer.test.ts

@@ -11,6 +11,27 @@ const ZERO_USAGE = {
   totalTokens: 0,
   totalTokens: 0,
 } as const;
 } as const;
 
 
+function createCaptureLogger() {
+  const infos: string[] = [];
+  const warnings: string[] = [];
+
+  const logger = {
+    debug() {},
+    info(message: string) {
+      infos.push(message);
+    },
+    warn(message: string) {
+      warnings.push(message);
+    },
+    error() {},
+    child() {
+      return logger;
+    },
+  };
+
+  return { logger, infos, warnings };
+}
+
 describe("WriterAgent", () => {
 describe("WriterAgent", () => {
   afterEach(() => {
   afterEach(() => {
     vi.restoreAllMocks();
     vi.restoreAllMocks();
@@ -33,6 +54,7 @@ describe("WriterAgent", () => {
         "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
         "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
         "| --- | --- | --- | --- | --- | --- | --- |",
         "| --- | --- | --- | --- | --- | --- | --- |",
         "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
         "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
+        "| old-seal | 3 | artifact | open | 12 | 40 | Old seal detour |",
         "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
         "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
       ].join("\n"), "utf-8"),
       ].join("\n"), "utf-8"),
       writeFile(join(storyDir, "chapter_summaries.md"), [
       writeFile(join(storyDir, "chapter_summaries.md"), [
@@ -43,9 +65,31 @@ describe("WriterAgent", () => {
         "| 98 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
         "| 98 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
         "| 99 | Locked Gate | Lin Yue | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | mentor-oath advanced | focused | decision |",
         "| 99 | Locked Gate | Lin Yue | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | mentor-oath advanced | focused | decision |",
       ].join("\n"), "utf-8"),
       ].join("\n"), "utf-8"),
-      writeFile(join(storyDir, "subplot_board.md"), "# 支线进度板\n", "utf-8"),
-      writeFile(join(storyDir, "emotional_arcs.md"), "# 情感弧线\n", "utf-8"),
-      writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
+      writeFile(join(storyDir, "subplot_board.md"), [
+        "# 支线进度板",
+        "",
+        "| 支线ID | 支线名 | 相关角色 | 起始章 | 最近活跃章 | 距今章数 | 状态 | 进度概述 | 回收ETA |",
+        "| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+        "| SP-mentor | 师债线 | Lin Yue | 8 | 99 | 1 | active | 师债继续推进 | 101 |",
+        "| SP-seal | 旧印支线 | Guildmaster Ren | 3 | 12 | 88 | closed | 旧印已回收 | 12 |",
+      ].join("\n"), "utf-8"),
+      writeFile(join(storyDir, "emotional_arcs.md"), [
+        "# 情感弧线",
+        "",
+        "| 角色 | 章节 | 情绪状态 | 触发事件 | 强度(1-10) | 弧线方向 |",
+        "| --- | --- | --- | --- | --- | --- |",
+        "| Lin Yue | 40 | 麻木 | 旧印支线拖延 | 4 | 停滞 |",
+        "| Lin Yue | 99 | 紧绷 | 师债重新压上来 | 8 | 收紧 |",
+      ].join("\n"), "utf-8"),
+      writeFile(join(storyDir, "character_matrix.md"), [
+        "# 角色交互矩阵",
+        "",
+        "### 角色档案",
+        "| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |",
+        "| --- | --- | --- | --- | --- | --- | --- | --- |",
+        "| Lin Yue | oath | restraint | clipped | stubborn | self | repay debt | find mentor |",
+        "| Guildmaster Ren | guild | swagger | loud | opportunistic | rival | stall Mara | seize seal |",
+      ].join("\n"), "utf-8"),
     ]);
     ]);
 
 
     const agent = new WriterAgent({
     const agent = new WriterAgent({
@@ -56,7 +100,7 @@ describe("WriterAgent", () => {
         defaults: {
         defaults: {
           temperature: 0.7,
           temperature: 0.7,
           maxTokens: 4096,
           maxTokens: 4096,
-          thinkingBudget: 0,
+          thinkingBudget: 0, maxTokensCap: null,
           extra: {},
           extra: {},
         },
         },
       },
       },
@@ -127,6 +171,11 @@ describe("WriterAgent", () => {
         contextPackage: {
         contextPackage: {
           chapter: 100,
           chapter: 100,
           selectedContext: [
           selectedContext: [
+            {
+              source: "story/volume_outline.md",
+              reason: "Anchor the current beat.",
+              excerpt: "Bring the focus back to the mentor oath conflict.",
+            },
             {
             {
               source: "story/chapter_summaries.md#99",
               source: "story/chapter_summaries.md#99",
               reason: "Relevant episodic memory.",
               reason: "Relevant episodic memory.",
@@ -153,9 +202,123 @@ describe("WriterAgent", () => {
       });
       });
 
 
       const settlePrompt = (chatSpy.mock.calls[2]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
       const settlePrompt = (chatSpy.mock.calls[2]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
+      expect(settlePrompt).toContain("## 本章控制输入");
       expect(settlePrompt).toContain("story/chapter_summaries.md#99");
       expect(settlePrompt).toContain("story/chapter_summaries.md#99");
       expect(settlePrompt).toContain("| 99 | Locked Gate |");
       expect(settlePrompt).toContain("| 99 | Locked Gate |");
       expect(settlePrompt).not.toContain("| 1 | Guild Trail |");
       expect(settlePrompt).not.toContain("| 1 | Guild Trail |");
+      expect(settlePrompt).not.toContain("old-seal");
+      expect(settlePrompt).not.toContain("Guildmaster Ren");
+      expect(settlePrompt).not.toContain("| Lin Yue | 40 | 麻木 |");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("logs localized phase messages for Chinese books", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-writer-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    const { logger, infos } = createCaptureLogger();
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n", "utf-8"),
+      writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), "# 当前状态\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# 伏笔池\n", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), "# 章节摘要\n", "utf-8"),
+      writeFile(join(storyDir, "subplot_board.md"), "# 支线进度板\n", "utf-8"),
+      writeFile(join(storyDir, "emotional_arcs.md"), "# 情感弧线\n", "utf-8"),
+      writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
+    ]);
+
+    const agent = new WriterAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+      logger,
+    });
+
+    vi.spyOn(WriterAgent.prototype as never, "chat" as never)
+      .mockResolvedValueOnce({
+        content: [
+          "=== CHAPTER_TITLE ===",
+          "试炼前夜",
+          "",
+          "=== CHAPTER_CONTENT ===",
+          "林越在破庙外停住脚步,想起师门旧债。",
+          "",
+          "=== PRE_WRITE_CHECK ===",
+          "- ok",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: "=== OBSERVATIONS ===\n- observed",
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: [
+          "=== POST_SETTLEMENT ===",
+          "| 伏笔变动 | mentor-oath 推进 | 同步更新伏笔池 |",
+          "",
+          "=== UPDATED_STATE ===",
+          "状态卡",
+          "",
+          "=== UPDATED_HOOKS ===",
+          "伏笔池",
+          "",
+          "=== CHAPTER_SUMMARY ===",
+          "| 1 | 试炼前夜 | 林越 | 林越记起师门旧债 | 决心加深 | mentor-oath advanced | tense | setup |",
+          "",
+          "=== UPDATED_SUBPLOTS ===",
+          "支线板",
+          "",
+          "=== UPDATED_EMOTIONAL_ARCS ===",
+          "情感弧线",
+          "",
+          "=== UPDATED_CHARACTER_MATRIX ===",
+          "角色矩阵",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    try {
+      await agent.writeChapter({
+        book: {
+          id: "writer-book",
+          title: "Writer Book",
+          platform: "tomato",
+          genre: "xuanhuan",
+          status: "active",
+          targetChapters: 120,
+          chapterWordCount: 2200,
+          language: "zh",
+          createdAt: "2026-03-23T00:00:00.000Z",
+          updatedAt: "2026-03-23T00:00:00.000Z",
+        },
+        bookDir,
+        chapterNumber: 1,
+        lengthSpec: buildLengthSpec(220, "zh"),
+      });
+
+      expect(infos).toEqual(expect.arrayContaining([
+        "阶段 1:创作正文(第1章)",
+        "阶段 2:状态结算(第1章,18字)",
+        "阶段 2a:提取第1章事实",
+        "阶段 2b:把观察结果回写到真相文件",
+      ]));
     } finally {
     } finally {
       await rm(root, { recursive: true, force: true });
       await rm(root, { recursive: true, force: true });
     }
     }

+ 301 - 78
packages/core/src/agents/architect.ts

@@ -21,6 +21,7 @@ export class ArchitectAgent extends BaseAgent {
   async generateFoundation(book: BookConfig, externalContext?: string): Promise<ArchitectOutput> {
   async generateFoundation(book: BookConfig, externalContext?: string): Promise<ArchitectOutput> {
     const { profile: gp, body: genreBody } =
     const { profile: gp, body: genreBody } =
       await readGenreProfile(this.ctx.projectRoot, book.genre);
       await readGenreProfile(this.ctx.projectRoot, book.genre);
+    const resolvedLanguage = book.language ?? gp.language;
 
 
     const contextBlock = externalContext
     const contextBlock = externalContext
       ? `\n\n## 外部指令\n以下是来自外部系统的创作指令,请将其融入设定中:\n\n${externalContext}\n`
       ? `\n\n## 外部指令\n以下是来自外部系统的创作指令,请将其融入设定中:\n\n${externalContext}\n`
@@ -39,24 +40,34 @@ export class ArchitectAgent extends BaseAgent {
       ? "- 需要年代考据支撑(在 book_rules 中设置 eraConstraints)"
       ? "- 需要年代考据支撑(在 book_rules 中设置 eraConstraints)"
       : "";
       : "";
 
 
-    const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新的${gp.name}小说生成完整的基础设定。${contextBlock}
+    const storyBiblePrompt = resolvedLanguage === "en"
+      ? `Use structured second-level headings:
+## 01_Worldview
+World setting, historical-social frame, and core rules
 
 
-要求:
-- 平台:${book.platform}
-- 题材:${gp.name}(${book.genre})
-- 目标章数:${book.targetChapters}章
-- 每章字数:${book.chapterWordCount}字
+## 02_Protagonist
+Protagonist setup (identity / advantage / personality core / behavioral boundaries)
 
 
-## 题材特征
+## 03_Factions_and_Characters
+Major factions and important supporting characters (for each: name, identity, motivation, relationship to protagonist, independent goal)
 
 
-${genreBody}
+## 04_Geography_and_Environment
+Map / scene design and environmental traits
 
 
-## 生成要求
+## 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
 
 
-你需要生成以下内容,每个部分用 === SECTION: <name> === 分隔:
+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
 
 
-=== SECTION: story_bible ===
-用结构化二级标题组织:
+Core blurb principle:
+- The blurb is product copy that must make readers want to click`
+      : `用结构化二级标题组织:
 ## 01_世界观
 ## 01_世界观
 世界观设定、核心规则体系
 世界观设定、核心规则体系
 
 
@@ -85,18 +96,52 @@ ${genreBody}
 简介核心原则:
 简介核心原则:
 - 简介 = 产品宣传语,必须让读者产生"我要点开看"的冲动
 - 简介 = 产品宣传语,必须让读者产生"我要点开看"的冲动
 - 可以从剧情设定、人设、或某个精彩片段切入
 - 可以从剧情设定、人设、或某个精彩片段切入
-- 必须有噱头(如"凡是被写在笔记本上的名字,最后都得死")
+- 必须有噱头(如"凡是被写在笔记本上的名字,最后都得死")`;
 
 
-=== SECTION: volume_outline ===
-卷纲规划,每卷包含:卷名、章节范围、核心冲突、关键转折、收益目标
+    const volumeOutlinePrompt = resolvedLanguage === "en"
+      ? `Volume plan. For each volume include: title, chapter range, core conflict, key turning points, and payoff goal
+
+### 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章:抛出核心冲突(主角立即面临困境/危机/选择),禁止大段背景灌输
 - 第1章:抛出核心冲突(主角立即面临困境/危机/选择),禁止大段背景灌输
 - 第2章:展示金手指/核心能力(主角如何应对第1章的困境),让读者看到爽点预期
 - 第2章:展示金手指/核心能力(主角如何应对第1章的困境),让读者看到爽点预期
-- 第3章:明确短期目标(主角确立第一个具体可达成的目标),给读者追读理由
+- 第3章:明确短期目标(主角确立第一个具体可达成的目标),给读者追读理由`;
 
 
-=== SECTION: book_rules ===
-生成 book_rules.md 格式的 YAML frontmatter + 叙事指导,包含:
+    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
+---
+
+## Narrative Perspective
+(Describe the narrative perspective and style)
+
+## Core Conflict Driver
+(Describe the book's core conflict and propulsion)
+\`\`\``
+      : `生成 book_rules.md 格式的 YAML frontmatter + 叙事指导,包含:
 \`\`\`
 \`\`\`
 ---
 ---
 version: "1.0"
 version: "1.0"
@@ -123,10 +168,20 @@ enableFullCastTracking: false
 
 
 ## 核心冲突驱动
 ## 核心冲突驱动
 (描述本书的核心矛盾和驱动力)
 (描述本书的核心矛盾和驱动力)
-\`\`\`
-
-=== SECTION: current_state ===
-初始状态卡(第0章),包含:
+\`\`\``;
+
+    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 |
 | 当前章节 | 0 |
@@ -135,13 +190,25 @@ enableFullCastTracking: false
 | 当前目标 | (第一个目标) |
 | 当前目标 | (第一个目标) |
 | 当前限制 | (初始限制) |
 | 当前限制 | (初始限制) |
 | 当前敌我 | (初始关系) |
 | 当前敌我 | (初始关系) |
-| 当前冲突 | (第一个冲突) |
-
-=== SECTION: pending_hooks ===
-初始伏笔池(Markdown表格):
-| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |
-
-生成内容必须:
+| 当前冲突 | (第一个冲突) |`;
+
+    const pendingHooksPrompt = resolvedLanguage === "en"
+      ? `Initial hook pool (Markdown table):
+| hook_id | start_chapter | type | status | latest_progress | expected_payoff | notes |`
+      : `初始伏笔池(Markdown表格):
+| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |`;
+
+    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}平台口味
 1. 符合${book.platform}平台口味
 2. 符合${gp.name}题材特征
 2. 符合${gp.name}题材特征
 ${numericalBlock}
 ${numericalBlock}
@@ -151,7 +218,39 @@ ${eraBlock}
 4. 伏笔前后呼应,不留悬空线
 4. 伏笔前后呼应,不留悬空线
 5. 配角有独立动机,不是工具人`;
 5. 配角有独立动机,不是工具人`;
 
 
-    const resolvedLanguage = book.language ?? gp.language;
+    const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新的${gp.name}小说生成完整的基础设定。${contextBlock}
+
+要求:
+- 平台:${book.platform}
+- 题材:${gp.name}(${book.genre})
+- 目标章数:${book.targetChapters}章
+- 每章字数:${book.chapterWordCount}字
+
+## 题材特征
+
+${genreBody}
+
+## 生成要求
+
+你需要生成以下内容,每个部分用 === SECTION: <name> === 分隔:
+
+=== SECTION: story_bible ===
+${storyBiblePrompt}
+
+=== SECTION: volume_outline ===
+${volumeOutlinePrompt}
+
+=== SECTION: book_rules ===
+${bookRulesPrompt}
+
+=== SECTION: current_state ===
+${currentStatePrompt}
+
+=== SECTION: pending_hooks ===
+${pendingHooksPrompt}
+
+${finalRequirementsPrompt}`;
+
     const langPrefix = resolvedLanguage === "en"
     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`
       ? `【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`
       : "";
       : "";
@@ -171,6 +270,7 @@ ${eraBlock}
     bookDir: string,
     bookDir: string,
     output: ArchitectOutput,
     output: ArchitectOutput,
     numericalSystem: boolean = true,
     numericalSystem: boolean = true,
+    language: "zh" | "en" = "zh",
   ): Promise<void> {
   ): Promise<void> {
     const storyDir = join(bookDir, "story");
     const storyDir = join(bookDir, "story");
     await mkdir(storyDir, { recursive: true });
     await mkdir(storyDir, { recursive: true });
@@ -187,7 +287,9 @@ ${eraBlock}
       writes.push(
       writes.push(
         writeFile(
         writeFile(
           join(storyDir, "particle_ledger.md"),
           join(storyDir, "particle_ledger.md"),
-          "# 资源账本\n\n| 章节 | 期初值 | 来源 | 完整度 | 增量 | 期末值 | 依据 |\n|------|--------|------|--------|------|--------|------|\n| 0 | 0 | 初始化 | - | 0 | 0 | 开书初始 |\n",
+          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",
           "utf-8",
         ),
         ),
       );
       );
@@ -197,17 +299,23 @@ ${eraBlock}
     writes.push(
     writes.push(
       writeFile(
       writeFile(
         join(storyDir, "subplot_board.md"),
         join(storyDir, "subplot_board.md"),
-        "# 支线进度板\n\n| 支线ID | 支线名 | 相关角色 | 起始章 | 最近活跃章 | 距今章数 | 状态 | 进度概述 | 回收ETA |\n|--------|--------|----------|--------|------------|----------|------|----------|---------|\n",
+        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",
         "utf-8",
       ),
       ),
       writeFile(
       writeFile(
         join(storyDir, "emotional_arcs.md"),
         join(storyDir, "emotional_arcs.md"),
-        "# 情感弧线\n\n| 角色 | 章节 | 情绪状态 | 触发事件 | 强度(1-10) | 弧线方向 |\n|------|------|----------|----------|------------|----------|\n",
+        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",
         "utf-8",
       ),
       ),
       writeFile(
       writeFile(
         join(storyDir, "character_matrix.md"),
         join(storyDir, "character_matrix.md"),
-        "# 角色交互矩阵\n\n### 角色档案\n| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |\n|------|----------|----------|----------|----------|------------|----------|----------|\n\n### 相遇记录\n| 角色A | 角色B | 首次相遇章 | 最近交互章 | 关系性质 | 关系变化 |\n|-------|-------|------------|------------|----------|----------|\n\n### 信息边界\n| 角色 | 已知信息 | 未知信息 | 信息来源章 |\n|------|----------|----------|------------|\n",
+        language === "en"
+          ? "# Character Matrix\n\n### Character Profiles\n| Character | Core Tags | Contrast Detail | Speech Style | Personality Core | Relationship to Protagonist | Core Motivation | Current Goal |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n\n### Encounter Log\n| Character A | Character B | First Meeting Chapter | Latest Interaction Chapter | Relationship Type | Relationship Change |\n| --- | --- | --- | --- | --- | --- |\n\n### Information Boundaries\n| Character | Known Information | Unknown Information | Source Chapter |\n| --- | --- | --- | --- |\n"
+          : "# 角色交互矩阵\n\n### 角色档案\n| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |\n|------|----------|----------|----------|----------|------------|----------|----------|\n\n### 相遇记录\n| 角色A | 角色B | 首次相遇章 | 最近交互章 | 关系性质 | 关系变化 |\n|-------|-------|------------|------------|----------|----------|\n\n### 信息边界\n| 角色 | 已知信息 | 未知信息 | 信息来源章 |\n|------|----------|----------|------------|\n",
         "utf-8",
         "utf-8",
       ),
       ),
     );
     );
@@ -227,6 +335,7 @@ ${eraBlock}
   ): Promise<ArchitectOutput> {
   ): Promise<ArchitectOutput> {
     const { profile: gp, body: genreBody } =
     const { profile: gp, body: genreBody } =
       await readGenreProfile(this.ctx.projectRoot, book.genre);
       await readGenreProfile(this.ctx.projectRoot, book.genre);
+    const resolvedLanguage = book.language ?? gp.language;
 
 
     const contextBlock = externalContext
     const contextBlock = externalContext
       ? `\n\n## 外部指令\n${externalContext}\n`
       ? `\n\n## 外部指令\n${externalContext}\n`
@@ -245,35 +354,23 @@ ${eraBlock}
       ? "- 需要年代考据支撑(在 book_rules 中设置 eraConstraints)"
       ? "- 需要年代考据支撑(在 book_rules 中设置 eraConstraints)"
       : "";
       : "";
 
 
-    const systemPrompt = `你是一个专业的网络小说架构师。你的任务是从已有的小说正文中反向推导完整的基础设定。${contextBlock}
-
-## 工作模式
-
-这不是从零创建,而是从已有正文中提取和推导。你需要:
-1. 从正文中提取世界观、势力、角色、力量体系 → 生成 story_bible
-2. 从叙事结构推断卷纲 → 生成 volume_outline(已有章节的回顾 + 预测后续方向)
-3. 从角色行为推断主角锁定和禁忌 → 生成 book_rules
-4. 从最新章节状态推断 current_state(反映最后一章结束时的状态)
-5. 从正文中识别已埋伏笔 → 生成 pending_hooks
-
-## 书籍信息
-
-- 标题:${book.title}
-- 平台:${book.platform}
-- 题材:${gp.name}(${book.genre})
-- 目标章数:${book.targetChapters}章
-- 每章字数:${book.chapterWordCount}字
-
-## 题材特征
+    const storyBiblePrompt = resolvedLanguage === "en"
+      ? `Extract from the source text and organize with structured second-level headings:
+## 01_Worldview
+Extracted world setting, core rules, and frame
 
 
-${genreBody}
+## 02_Protagonist
+Inferred protagonist setup (identity / advantage / personality core / behavioral boundaries)
 
 
-## 生成要求
+## 03_Factions_and_Characters
+Factions and important supporting characters that appear in the source text
 
 
-你需要生成以下内容,每个部分用 === SECTION: <name> === 分隔:
+## 04_Geography_and_Environment
+Locations, environments, and scene traits drawn from the source text
 
 
-=== SECTION: story_bible ===
-从正文中提取,用结构化二级标题组织:
+## 05_Title_and_Blurb
+Keep the original title "${book.title}" and generate a matching blurb from the source text`
+      : `从正文中提取,用结构化二级标题组织:
 ## 01_世界观
 ## 01_世界观
 从正文中提取的世界观设定、核心规则体系
 从正文中提取的世界观设定、核心规则体系
 
 
@@ -287,16 +384,48 @@ ${genreBody}
 从正文中出现的地图/场景设定、环境特色
 从正文中出现的地图/场景设定、环境特色
 
 
 ## 05_书名与简介
 ## 05_书名与简介
-保留原书名"${book.title}",根据正文内容生成简介
-
-=== SECTION: volume_outline ===
-基于已有正文反推卷纲:
+保留原书名"${book.title}",根据正文内容生成简介`;
+
+    const volumeOutlinePrompt = resolvedLanguage === "en"
+      ? `Infer the volume plan from existing text:
+- Existing chapters: review the actual structure already present
+- Future projection: predict later directions from active hooks and plot momentum
+For each volume include: title, chapter range, core conflict, and key turning points`
+      : `基于已有正文反推卷纲:
 - 已有章节部分:根据实际内容回顾每卷的结构
 - 已有章节部分:根据实际内容回顾每卷的结构
 - 后续预测部分:基于已有伏笔和剧情走向预测未来方向
 - 后续预测部分:基于已有伏笔和剧情走向预测未来方向
-每卷包含:卷名、章节范围、核心冲突、关键转折
+每卷包含:卷名、章节范围、核心冲突、关键转折`;
 
 
-=== SECTION: book_rules ===
-从正文中角色行为反推 book_rules.md 格式的 YAML frontmatter + 叙事指导:
+    const bookRulesPrompt = resolvedLanguage === "en"
+      ? `Infer book_rules.md as YAML frontmatter plus narrative guidance from character behavior in the source text:
+\`\`\`
+---
+version: "1.0"
+protagonist:
+  name: (extract protagonist name from the text)
+  personalityLock: [(infer 3-5 personality keywords from behavior)]
+  behavioralConstraints: [(infer 3-5 behavioral constraints from behavior)]
+genreLock:
+  primary: ${book.genre}
+  forbidden: [(2-3 forbidden style intrusions)]
+${gp.numericalSystem ? `numericalSystemOverrides:
+  hardCap: (infer from the text)
+  resourceTypes: [(extract core resource types from the text)]` : ""}
+prohibitions:
+  - (infer 3-5 book-specific prohibitions from the text)
+chapterTypesOverride: []
+fatigueWordsOverride: []
+additionalAuditDimensions: []
+enableFullCastTracking: false
+---
+
+## Narrative Perspective
+(Infer the narrative perspective and style from the text)
+
+## Core Conflict Driver
+(Infer the book's core conflict and propulsion from the text)
+\`\`\``
+      : `从正文中角色行为反推 book_rules.md 格式的 YAML frontmatter + 叙事指导:
 \`\`\`
 \`\`\`
 ---
 ---
 version: "1.0"
 version: "1.0"
@@ -323,10 +452,20 @@ enableFullCastTracking: false
 
 
 ## 核心冲突驱动
 ## 核心冲突驱动
 (从正文推断本书的核心矛盾和驱动力)
 (从正文推断本书的核心矛盾和驱动力)
-\`\`\`
-
-=== SECTION: current_state ===
-反映最后一章结束时的状态卡:
+\`\`\``;
+
+    const currentStatePrompt = resolvedLanguage === "en"
+      ? `Reflect the state at the end of the latest chapter:
+| Field | Value |
+| --- | --- |
+| Current Chapter | (latest chapter number) |
+| Current Location | (location at the end of the latest chapter) |
+| Protagonist State | (state at the end of the latest chapter) |
+| Current Goal | (current goal) |
+| Current Constraint | (current constraint) |
+| Current Alliances | (current alliances / opposition) |
+| Current Conflict | (current conflict) |`
+      : `反映最后一章结束时的状态卡:
 | 字段 | 值 |
 | 字段 | 值 |
 |------|-----|
 |------|-----|
 | 当前章节 | (最后一章章节号) |
 | 当前章节 | (最后一章章节号) |
@@ -335,13 +474,25 @@ enableFullCastTracking: false
 | 当前目标 | (当前目标) |
 | 当前目标 | (当前目标) |
 | 当前限制 | (当前限制) |
 | 当前限制 | (当前限制) |
 | 当前敌我 | (当前敌我关系) |
 | 当前敌我 | (当前敌我关系) |
-| 当前冲突 | (当前冲突) |
+| 当前冲突 | (当前冲突) |`;
 
 
-=== SECTION: pending_hooks ===
-从正文中识别的所有伏笔(Markdown表格):
-| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |
+    const pendingHooksPrompt = resolvedLanguage === "en"
+      ? `Identify all active hooks from the source text (Markdown table):
+| hook_id | start_chapter | type | status | latest_progress | expected_payoff | notes |`
+      : `从正文中识别的所有伏笔(Markdown表格):
+| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |`;
 
 
-## 关键原则
+    const keyPrinciplesPrompt = resolvedLanguage === "en"
+      ? `## Key Principles
+
+1. Derive everything from the source text; do not invent unsupported settings
+2. Hook extraction must be complete: unresolved clues, hints, and foreshadowing all count
+3. Character inference must come from dialogue and behavior, not assumption
+4. Accuracy first; detailed is better than missing crucial information
+${numericalBlock}
+${powerBlock}
+${eraBlock}`
+      : `## 关键原则
 
 
 1. 一切从正文出发,不要臆造正文中没有的设定
 1. 一切从正文出发,不要臆造正文中没有的设定
 2. 伏笔识别要完整:悬而未决的线索、暗示、预告都算
 2. 伏笔识别要完整:悬而未决的线索、暗示、预告都算
@@ -351,11 +502,62 @@ ${numericalBlock}
 ${powerBlock}
 ${powerBlock}
 ${eraBlock}`;
 ${eraBlock}`;
 
 
+    const systemPrompt = `你是一个专业的网络小说架构师。你的任务是从已有的小说正文中反向推导完整的基础设定。${contextBlock}
+
+## 工作模式
+
+这不是从零创建,而是从已有正文中提取和推导。你需要:
+1. 从正文中提取世界观、势力、角色、力量体系 → 生成 story_bible
+2. 从叙事结构推断卷纲 → 生成 volume_outline(已有章节的回顾 + 预测后续方向)
+3. 从角色行为推断主角锁定和禁忌 → 生成 book_rules
+4. 从最新章节状态推断 current_state(反映最后一章结束时的状态)
+5. 从正文中识别已埋伏笔 → 生成 pending_hooks
+
+## 书籍信息
+
+- 标题:${book.title}
+- 平台:${book.platform}
+- 题材:${gp.name}(${book.genre})
+- 目标章数:${book.targetChapters}章
+- 每章字数:${book.chapterWordCount}字
+
+## 题材特征
+
+${genreBody}
+
+## 生成要求
+
+你需要生成以下内容,每个部分用 === SECTION: <name> === 分隔:
+
+=== SECTION: story_bible ===
+${storyBiblePrompt}
+
+=== SECTION: volume_outline ===
+${volumeOutlinePrompt}
+
+=== SECTION: book_rules ===
+${bookRulesPrompt}
+
+=== SECTION: current_state ===
+${currentStatePrompt}
+
+=== SECTION: pending_hooks ===
+${pendingHooksPrompt}
+
+${keyPrinciplesPrompt}`;
+
+    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 an imported ${gp.name} novel titled "${book.title}". Write everything in English.\n\n${chaptersText}`
+      : `以下是《${book.title}》的全部已有正文,请从中反向推导完整基础设定:\n\n${chaptersText}`;
+
     const response = await this.chat([
     const response = await this.chat([
-      { role: "system", content: systemPrompt },
+      { role: "system", content: langPrefix + systemPrompt },
       {
       {
         role: "user",
         role: "user",
-        content: `以下是《${book.title}》的全部已有正文,请从中反向推导完整基础设定:\n\n${chaptersText}`,
+        content: userMessage,
       },
       },
     ], { maxTokens: 16384, temperature: 0.5 });
     ], { maxTokens: 16384, temperature: 0.5 });
 
 
@@ -446,7 +648,13 @@ prohibitions:
         `=== SECTION: ${name} ===\\s*([\\s\\S]*?)(?==== SECTION:|$)`,
         `=== SECTION: ${name} ===\\s*([\\s\\S]*?)(?==== SECTION:|$)`,
       );
       );
       const match = content.match(regex);
       const match = content.match(regex);
-      return match?.[1]?.trim() ?? `[${name} 生成失败,需要重新生成]`;
+      const section = match?.[1]?.trim();
+      if (!section) {
+        return `[${name} 生成失败,需要重新生成]`;
+      }
+      return name === "pending_hooks"
+        ? this.stripTrailingAssistantCoda(section)
+        : section;
     };
     };
 
 
     return {
     return {
@@ -457,4 +665,19 @@ prohibitions:
       pendingHooks: extract("pending_hooks"),
       pendingHooks: extract("pending_hooks"),
     };
     };
   }
   }
+
+  private stripTrailingAssistantCoda(section: string): string {
+    const lines = section.split("\n");
+    const cutoff = lines.findIndex((line) => {
+      const trimmed = line.trim();
+      if (!trimmed) return false;
+      return /^(如果(?:你愿意|需要|想要|希望)|If (?:you(?:'d)? like|you want|needed)|I can (?:continue|next))/i.test(trimmed);
+    });
+
+    if (cutoff < 0) {
+      return section;
+    }
+
+    return lines.slice(0, cutoff).join("\n").trimEnd();
+  }
 }
 }

+ 264 - 17
packages/core/src/agents/chapter-analyzer.ts

@@ -4,6 +4,7 @@ import type { GenreProfile } from "../models/genre-profile.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { parseWriterOutput, type ParsedWriterOutput } from "./writer-parser.js";
 import { parseWriterOutput, type ParsedWriterOutput } from "./writer-parser.js";
 import { resolveLengthCountingMode } from "../utils/length-metrics.js";
 import { resolveLengthCountingMode } from "../utils/length-metrics.js";
+import { retrieveMemorySelection } from "../utils/memory-retrieval.js";
 import { readFile } from "node:fs/promises";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 
 
@@ -24,32 +25,48 @@ export class ChapterAnalyzerAgent extends BaseAgent {
 
 
   async analyzeChapter(input: AnalyzeChapterInput): Promise<AnalyzeChapterOutput> {
   async analyzeChapter(input: AnalyzeChapterInput): Promise<AnalyzeChapterOutput> {
     const { book, bookDir, chapterNumber, chapterContent, chapterTitle } = input;
     const { book, bookDir, chapterNumber, chapterContent, chapterTitle } = input;
+    const { profile: genreProfile, body: genreBody } =
+      await readGenreProfile(this.ctx.projectRoot, book.genre);
+    const resolvedLanguage = book.language ?? genreProfile.language;
 
 
     // Read current truth files (same set as writer.ts)
     // Read current truth files (same set as writer.ts)
     const [
     const [
       currentState, ledger, hooks,
       currentState, ledger, hooks,
-      chapterSummaries, subplotBoard, emotionalArcs, characterMatrix,
+      subplotBoard, emotionalArcs, characterMatrix,
       storyBible, volumeOutline,
       storyBible, volumeOutline,
     ] = await Promise.all([
     ] = await Promise.all([
-      this.readFileOrDefault(join(bookDir, "story/current_state.md")),
-      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")),
-      this.readFileOrDefault(join(bookDir, "story/story_bible.md")),
-      this.readFileOrDefault(join(bookDir, "story/volume_outline.md")),
+      this.readFileOrDefault(join(bookDir, "story/current_state.md"), resolvedLanguage),
+      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),
     ]);
     ]);
-
-    const { profile: genreProfile, body: genreBody } =
-      await readGenreProfile(this.ctx.projectRoot, book.genre);
     const parsedBookRules = await readBookRules(bookDir);
     const parsedBookRules = await readBookRules(bookDir);
     const bookRulesBody = parsedBookRules?.body ?? "";
     const bookRulesBody = parsedBookRules?.body ?? "";
+    const memorySelection = await retrieveMemorySelection({
+      bookDir,
+      chapterNumber,
+      goal: this.buildMemoryGoal(chapterTitle, chapterContent),
+      outlineNode: this.findOutlineNode(volumeOutline, chapterNumber),
+    });
+    const chapterSummaries = this.renderSummarySnapshot(
+      memorySelection.summaries,
+      resolvedLanguage,
+    );
 
 
-    const systemPrompt = this.buildSystemPrompt(book, genreProfile, genreBody, bookRulesBody);
+    const systemPrompt = this.buildSystemPrompt(
+      book,
+      genreProfile,
+      genreBody,
+      bookRulesBody,
+      resolvedLanguage,
+    );
 
 
     const userPrompt = this.buildUserPrompt({
     const userPrompt = this.buildUserPrompt({
+      language: resolvedLanguage,
       chapterNumber,
       chapterNumber,
       chapterContent,
       chapterContent,
       chapterTitle,
       chapterTitle,
@@ -76,7 +93,13 @@ export class ChapterAnalyzerAgent extends BaseAgent {
     const output = parseWriterOutput(chapterNumber, response.content, genreProfile, countingMode);
     const output = parseWriterOutput(chapterNumber, response.content, genreProfile, countingMode);
 
 
     // If LLM didn't return a title, use the one from input or derive from chapter number
     // If LLM didn't return a title, use the one from input or derive from chapter number
-    if (output.title === `第${chapterNumber}章` && chapterTitle) {
+    if (
+      chapterTitle
+      && (
+        output.title === this.defaultChapterTitle(chapterNumber, resolvedLanguage)
+        || output.title === `第${chapterNumber}章`
+      )
+    ) {
       return { ...output, title: chapterTitle };
       return { ...output, title: chapterTitle };
     }
     }
 
 
@@ -88,7 +111,103 @@ export class ChapterAnalyzerAgent extends BaseAgent {
     genreProfile: GenreProfile,
     genreProfile: GenreProfile,
     genreBody: string,
     genreBody: string,
     bookRulesBody: string,
     bookRulesBody: string,
+    language: "zh" | "en",
   ): string {
   ): string {
+    if (language === "en") {
+      const numericalBlock = genreProfile.numericalSystem
+        ? "\n- This genre tracks numerical/resources systems; UPDATED_LEDGER must capture every resource change shown in the chapter."
+        : "\n- This genre has no numerical system; leave UPDATED_LEDGER empty.";
+
+      return `【LANGUAGE OVERRIDE】ALL output MUST be in English. The === TAG === markers remain unchanged.
+
+You are a fiction continuity analyst. Analyze a finished chapter, extract every state change, and update the tracking files.
+
+## Working Mode
+
+You are not writing new prose. You are reading completed chapter text and updating the book's truth files.
+1. Read the chapter carefully and extract all important facts.
+2. Update the existing tracking files incrementally rather than rebuilding them from scratch.
+3. Keep the output contract identical to the writer pipeline.
+
+## What To Extract
+
+- Character entrances, exits, injuries, breakthroughs, deaths, and other status changes
+- Location movement and scene transitions
+- Item or resource gains and losses
+- Hook setup, advancement, and payoff
+- Emotional arc movement
+- Subplot progress
+- Relationship changes and information-boundary changes
+
+## Book Information
+
+- Title: ${book.title}
+- Genre: ${genreProfile.name} (${book.genre})
+- Platform: ${book.platform}
+${numericalBlock}
+
+## Genre Guidance
+
+${genreBody}
+
+${bookRulesBody ? `## Book Rules\n\n${bookRulesBody}` : ""}
+
+## Output Format
+
+Use === TAG === delimiters exactly as shown:
+
+=== CHAPTER_TITLE ===
+(Extract or infer the chapter title. Output title text only.)
+
+=== CHAPTER_CONTENT ===
+(Repeat the original chapter content exactly. Do not rewrite.)
+
+=== PRE_WRITE_CHECK ===
+(Leave empty in analysis mode.)
+
+=== POST_SETTLEMENT ===
+(Leave empty in analysis mode.)
+
+=== UPDATED_STATE ===
+Updated state card as a Markdown table reflecting the end-of-chapter state:
+| Field | Value |
+| --- | --- |
+| Current Chapter | {chapter_number} |
+| Current Location | ... |
+| Protagonist State | ... |
+| Current Goal | ... |
+| Current Constraint | ... |
+| Current Alliances | ... |
+| Current Conflict | ... |
+
+=== UPDATED_LEDGER ===
+(If the genre has a numerical system: output the fully updated resource ledger table. Otherwise leave empty.)
+
+=== UPDATED_HOOKS ===
+Updated hooks pool as a Markdown table with the latest status of every known hook:
+| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |
+
+=== CHAPTER_SUMMARY ===
+Single Markdown table row:
+| Chapter | Title | Characters | Key Events | State Changes | Hook Activity | Mood | Chapter Type |
+
+=== UPDATED_SUBPLOTS ===
+Updated subplot board (Markdown table)
+
+=== UPDATED_EMOTIONAL_ARCS ===
+Updated emotional arcs (Markdown table)
+
+=== UPDATED_CHARACTER_MATRIX ===
+Updated character interaction matrix (Markdown table)
+
+## Rules
+
+1. UPDATED_STATE and UPDATED_HOOKS must be incremental updates based on the current tracking files.
+2. Every factual change in the chapter must appear in the corresponding tracking file.
+3. Do not miss resource changes, movement, relationship changes, or information changes.
+4. Information boundaries in the character matrix must stay exact: each character only knows what they directly witnessed or learned.`;
+    }
+
     const numericalBlock = genreProfile.numericalSystem
     const numericalBlock = genreProfile.numericalSystem
       ? `\n- 本题材有数值/资源体系,你必须在 UPDATED_LEDGER 中追踪正文中出现的所有资源变动`
       ? `\n- 本题材有数值/资源体系,你必须在 UPDATED_LEDGER 中追踪正文中出现的所有资源变动`
       : `\n- 本题材无数值系统,UPDATED_LEDGER 留空`;
       : `\n- 本题材无数值系统,UPDATED_LEDGER 留空`;
@@ -183,6 +302,7 @@ ${bookRulesBody ? `## 本书规则\n\n${bookRulesBody}` : ""}
   }
   }
 
 
   private buildUserPrompt(params: {
   private buildUserPrompt(params: {
+    readonly language: "zh" | "en";
     readonly chapterNumber: number;
     readonly chapterNumber: number;
     readonly chapterContent: string;
     readonly chapterContent: string;
     readonly chapterTitle?: string;
     readonly chapterTitle?: string;
@@ -196,6 +316,52 @@ ${bookRulesBody ? `## 本书规则\n\n${bookRulesBody}` : ""}
     readonly emotionalArcs: string;
     readonly emotionalArcs: string;
     readonly characterMatrix: string;
     readonly characterMatrix: string;
   }): string {
   }): string {
+    if (params.language === "en") {
+      const titleLine = params.chapterTitle
+        ? `Chapter Title: ${params.chapterTitle}\n`
+        : "";
+
+      const ledgerBlock = params.ledger
+        ? `\n## Current Resource Ledger\n${params.ledger}\n`
+        : "";
+
+      const summariesBlock = params.chapterSummaries !== this.missingFilePlaceholder("en")
+        ? `\n## Existing Chapter Summaries\n${params.chapterSummaries}\n`
+        : "";
+
+      const subplotBlock = params.subplotBoard !== this.missingFilePlaceholder("en")
+        ? `\n## Current Subplot Board\n${params.subplotBoard}\n`
+        : "";
+
+      const emotionalBlock = params.emotionalArcs !== this.missingFilePlaceholder("en")
+        ? `\n## Current Emotional Arcs\n${params.emotionalArcs}\n`
+        : "";
+
+      const matrixBlock = params.characterMatrix !== this.missingFilePlaceholder("en")
+        ? `\n## Current Character Matrix\n${params.characterMatrix}\n`
+        : "";
+
+      return `Analyze chapter ${params.chapterNumber} and update all tracking files.
+${titleLine}
+## Chapter Content
+
+${params.chapterContent}
+
+## Current State
+${params.currentState}
+${ledgerBlock}
+## Current Hooks
+${params.hooks}
+${summariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}
+## Story Bible
+${params.storyBible}
+
+## Volume Outline
+${params.volumeOutline}
+
+Please return the result strictly in the === TAG === format.`;
+    }
+
     const titleLine = params.chapterTitle
     const titleLine = params.chapterTitle
       ? `章节标题:${params.chapterTitle}\n`
       ? `章节标题:${params.chapterTitle}\n`
       : "";
       : "";
@@ -241,11 +407,92 @@ ${params.volumeOutline}
 请严格按照 === TAG === 格式输出分析结果。`;
 请严格按照 === TAG === 格式输出分析结果。`;
   }
   }
 
 
-  private async readFileOrDefault(path: string): Promise<string> {
+  private buildMemoryGoal(chapterTitle: string | undefined, chapterContent: string): string {
+    return [chapterTitle ?? "", chapterContent.slice(0, 1500)]
+      .filter((part) => part.trim().length > 0)
+      .join("\n\n");
+  }
+
+  private findOutlineNode(volumeOutline: string, chapterNumber: number): string | undefined {
+    if (!volumeOutline || volumeOutline === this.missingFilePlaceholder("zh") || volumeOutline === this.missingFilePlaceholder("en")) {
+      return undefined;
+    }
+
+    const lines = volumeOutline.split("\n").map((line) => line.trim()).filter(Boolean);
+    const chapterPatterns = [
+      new RegExp(`^#+\\s*Chapter\\s*${chapterNumber}\\b`, "i"),
+      new RegExp(`^#+\\s*第\\s*${chapterNumber}\\s*章`),
+    ];
+
+    const heading = lines.find((line) => chapterPatterns.some((pattern) => pattern.test(line)));
+    if (!heading) return undefined;
+
+    const headingIndex = lines.indexOf(heading);
+    const nextLine = lines[headingIndex + 1];
+    return nextLine && !nextLine.startsWith("#") ? nextLine : heading.replace(/^#+\s*/, "");
+  }
+
+  private renderSummarySnapshot(
+    summaries: ReadonlyArray<{
+      chapter: number;
+      title: string;
+      characters: string;
+      events: string;
+      stateChanges: string;
+      hookActivity: string;
+      mood: string;
+      chapterType: string;
+    }>,
+    language: "zh" | "en",
+  ): string {
+    if (summaries.length === 0) {
+      return this.missingFilePlaceholder(language);
+    }
+
+    const header = language === "en"
+      ? [
+          "| Chapter | Title | Characters | Key Events | State Changes | Hook Activity | Mood | Chapter Type |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+        ]
+      : [
+          "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+        ];
+
+    const rows = summaries.map((summary) => [
+      summary.chapter,
+      summary.title,
+      summary.characters,
+      summary.events,
+      summary.stateChanges,
+      summary.hookActivity,
+      summary.mood,
+      summary.chapterType,
+    ].map((cell) => this.escapeTableCell(String(cell))).join(" | "));
+
+    return [
+      ...header,
+      ...rows.map((row) => `| ${row} |`),
+    ].join("\n");
+  }
+
+  private escapeTableCell(value: string): string {
+    return value.replace(/\|/g, "\\|").replace(/\n/g, "<br>");
+  }
+
+  private async readFileOrDefault(path: string, language: "zh" | "en"): Promise<string> {
     try {
     try {
       return await readFile(path, "utf-8");
       return await readFile(path, "utf-8");
     } catch {
     } catch {
-      return "(文件尚未创建)";
+      return this.missingFilePlaceholder(language);
     }
     }
   }
   }
+
+  private missingFilePlaceholder(language: "zh" | "en"): string {
+    return language === "en" ? "(file not created yet)" : "(文件尚未创建)";
+  }
+
+  private defaultChapterTitle(chapterNumber: number, language: "zh" | "en"): string {
+    return language === "en" ? `Chapter ${chapterNumber}` : `第${chapterNumber}章`;
+  }
 }
 }

+ 6 - 0
packages/core/src/agents/composer.ts

@@ -151,11 +151,17 @@ export class ComposerAgent extends BaseAgent {
         .filter(Boolean)
         .filter(Boolean)
         .join(" | "),
         .join(" | "),
     }));
     }));
+    const volumeSummaryEntries = memorySelection.volumeSummaries.map((summary) => ({
+      source: `story/volume_summaries.md#${summary.anchor}`,
+      reason: "Carry forward long-span arc memory compressed from earlier volumes.",
+      excerpt: `${summary.heading} | ${summary.content}`,
+    }));
 
 
     return [
     return [
       ...entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
       ...entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
       ...factEntries,
       ...factEntries,
       ...summaryEntries,
       ...summaryEntries,
+      ...volumeSummaryEntries,
       ...hookEntries,
       ...hookEntries,
     ];
     ];
   }
   }

+ 301 - 130
packages/core/src/agents/continuity.ts

@@ -3,7 +3,7 @@ import type { GenreProfile } from "../models/genre-profile.js";
 import type { BookRules } from "../models/book-rules.js";
 import type { BookRules } from "../models/book-rules.js";
 import type { FanficMode } from "../models/book.js";
 import type { FanficMode } from "../models/book.js";
 import type { ContextPackage, RuleStack } from "../models/input-governance.js";
 import type { ContextPackage, RuleStack } from "../models/input-governance.js";
-import { readGenreProfile, readBookRules } from "./rules-reader.js";
+import { readGenreProfile, readBookLanguage, readBookRules } from "./rules-reader.js";
 import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions.js";
 import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions.js";
 import { readFile, readdir } from "node:fs/promises";
 import { readFile, readdir } from "node:fs/promises";
 import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
 import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
@@ -28,50 +28,210 @@ export interface AuditIssue {
   readonly suggestion: string;
   readonly suggestion: string;
 }
 }
 
 
-// Dimension ID → name mapping
-const DIMENSION_MAP: Record<number, string> = {
-  1: "OOC检查",
-  2: "时间线检查",
-  3: "设定冲突",
-  4: "战力崩坏",
-  5: "数值检查",
-  6: "伏笔检查",
-  7: "节奏检查",
-  8: "文风检查",
-  9: "信息越界",
-  10: "词汇疲劳",
-  11: "利益链断裂",
-  12: "年代考据",
-  13: "配角降智",
-  14: "配角工具人化",
-  15: "爽点虚化",
-  16: "台词失真",
-  17: "流水账",
-  18: "知识库污染",
-  19: "视角一致性",
-  20: "段落等长",
-  21: "套话密度",
-  22: "公式化转折",
-  23: "列表式结构",
-  24: "支线停滞",
-  25: "弧线平坦",
-  26: "节奏单调",
-  27: "敏感词检查",
-  28: "正传事件冲突",
-  29: "未来信息泄露",
-  30: "世界规则跨书一致性",
-  31: "番外伏笔隔离",
-  32: "读者期待管理",
-  33: "大纲偏离检测",
-  34: "角色还原度",
-  35: "世界规则遵守",
-  36: "关系动态",
-  37: "正典事件一致性",
+type PromptLanguage = "zh" | "en";
+
+const DIMENSION_LABELS: Record<number, { readonly zh: string; readonly en: string }> = {
+  1: { zh: "OOC检查", en: "OOC Check" },
+  2: { zh: "时间线检查", en: "Timeline Check" },
+  3: { zh: "设定冲突", en: "Lore Conflict Check" },
+  4: { zh: "战力崩坏", en: "Power Scaling Check" },
+  5: { zh: "数值检查", en: "Numerical Consistency Check" },
+  6: { zh: "伏笔检查", en: "Hook Check" },
+  7: { zh: "节奏检查", en: "Pacing Check" },
+  8: { zh: "文风检查", en: "Style Check" },
+  9: { zh: "信息越界", en: "Information Boundary Check" },
+  10: { zh: "词汇疲劳", en: "Lexical Fatigue Check" },
+  11: { zh: "利益链断裂", en: "Incentive Chain Check" },
+  12: { zh: "年代考据", en: "Era Accuracy Check" },
+  13: { zh: "配角降智", en: "Side Character Competence Check" },
+  14: { zh: "配角工具人化", en: "Side Character Instrumentalization Check" },
+  15: { zh: "爽点虚化", en: "Payoff Dilution Check" },
+  16: { zh: "台词失真", en: "Dialogue Authenticity Check" },
+  17: { zh: "流水账", en: "Chronicle Drift Check" },
+  18: { zh: "知识库污染", en: "Knowledge Base Pollution Check" },
+  19: { zh: "视角一致性", en: "POV Consistency Check" },
+  20: { zh: "段落等长", en: "Paragraph Uniformity Check" },
+  21: { zh: "套话密度", en: "Cliche Density Check" },
+  22: { zh: "公式化转折", en: "Formulaic Twist Check" },
+  23: { zh: "列表式结构", en: "List-like Structure Check" },
+  24: { zh: "支线停滞", en: "Subplot Stagnation Check" },
+  25: { zh: "弧线平坦", en: "Arc Flatline Check" },
+  26: { zh: "节奏单调", en: "Pacing Monotony Check" },
+  27: { zh: "敏感词检查", en: "Sensitive Content Check" },
+  28: { zh: "正传事件冲突", en: "Mainline Canon Event Conflict" },
+  29: { zh: "未来信息泄露", en: "Future Knowledge Leak Check" },
+  30: { zh: "世界规则跨书一致性", en: "Cross-Book World Rule Check" },
+  31: { zh: "番外伏笔隔离", en: "Spinoff Hook Isolation Check" },
+  32: { zh: "读者期待管理", en: "Reader Expectation Check" },
+  33: { zh: "大纲偏离检测", en: "Outline Drift Check" },
+  34: { zh: "角色还原度", en: "Character Fidelity Check" },
+  35: { zh: "世界规则遵守", en: "World Rule Compliance Check" },
+  36: { zh: "关系动态", en: "Relationship Dynamics Check" },
+  37: { zh: "正典事件一致性", en: "Canon Event Consistency Check" },
 };
 };
 
 
+function containsChinese(text: string): boolean {
+  return /[\u4e00-\u9fff]/u.test(text);
+}
+
+function resolveGenreLabel(genreId: string, profileName: string, language: PromptLanguage): string {
+  if (language === "zh" || !containsChinese(profileName)) {
+    return profileName;
+  }
+
+  if (genreId === "other") {
+    return "general";
+  }
+
+  return genreId.replace(/[_-]+/g, " ");
+}
+
+function dimensionName(id: number, language: PromptLanguage): string | undefined {
+  return DIMENSION_LABELS[id]?.[language];
+}
+
+function joinLocalized(items: ReadonlyArray<string>, language: PromptLanguage): string {
+  return items.join(language === "en" ? ", " : "、");
+}
+
+function formatFanficSeverityNote(
+  severity: "critical" | "warning" | "info",
+  language: PromptLanguage,
+): string {
+  if (language === "en") {
+    return severity === "critical"
+      ? "Strict check."
+      : severity === "info"
+        ? "Log only; do not fail the chapter."
+        : "Warning level.";
+  }
+
+  return severity === "critical"
+    ? "(严格检查)"
+    : severity === "info"
+      ? "(仅记录,不判定失败)"
+      : "(警告级别)";
+}
+
+function buildDimensionNote(
+  id: number,
+  language: PromptLanguage,
+  gp: GenreProfile,
+  bookRules: BookRules | null,
+  fanficMode: FanficMode | undefined,
+  fanficConfig: ReturnType<typeof getFanficDimensionConfig> | undefined,
+): string {
+  const words = bookRules?.fatigueWordsOverride && bookRules.fatigueWordsOverride.length > 0
+    ? bookRules.fatigueWordsOverride
+    : gp.fatigueWords;
+
+  if (fanficConfig?.notes.has(id) && language === "zh") {
+    return fanficConfig.notes.get(id)!;
+  }
+
+  if (id === 1 && fanficMode === "ooc") {
+    return language === "en"
+      ? "In OOC mode, personality drift can be intentional; record only, do not fail. Evaluate against the character dossiers in fanfic_canon.md."
+      : "OOC模式下角色可偏离性格底色,此维度仅记录不判定失败。参照 fanfic_canon.md 角色档案评估偏离程度。";
+  }
+
+  if (id === 1 && fanficMode === "canon") {
+    return language === "en"
+      ? "Canon-faithful fanfic: characters must stay close to their original personality core. Evaluate against fanfic_canon.md character dossiers."
+      : "原作向同人:角色必须严格遵守性格底色。参照 fanfic_canon.md 角色档案中的性格底色和行为模式。";
+  }
+
+  if (id === 10 && words.length > 0) {
+    return language === "en"
+      ? `Fatigue words: ${words.join(", ")}. Also check AI tell markers (仿佛/不禁/宛如/竟然/忽然/猛地); warn when any appears more than once per 3,000 words.`
+      : `高疲劳词:${words.join("、")}。同时检查AI标记词(仿佛/不禁/宛如/竟然/忽然/猛地)密度,每3000字超过1次即warning`;
+  }
+
+  if (id === 15 && gp.satisfactionTypes.length > 0) {
+    return language === "en"
+      ? `Payoff types: ${gp.satisfactionTypes.join(", ")}`
+      : `爽点类型:${gp.satisfactionTypes.join("、")}`;
+  }
+
+  if (id === 12 && bookRules?.eraConstraints) {
+    const era = bookRules.eraConstraints;
+    const parts = [era.period, era.region].filter(Boolean);
+    if (parts.length > 0) {
+      return language === "en"
+        ? `Era: ${parts.join(", ")}`
+        : `年代:${parts.join(",")}`;
+    }
+  }
+
+  switch (id) {
+    case 19:
+      return language === "en"
+        ? "Check whether POV shifts are signaled clearly and stay consistent with the configured viewpoint."
+        : "检查视角切换是否有过渡、是否与设定视角一致";
+    case 24:
+      return language === "en"
+        ? "Cross-check subplot_board and chapter_summaries: if any subplot goes unmentioned or unadvanced for more than 5 chapters -> warning. If subplots exist but none move in the last 3 chapters -> warning."
+        : "对照 subplot_board 和 chapter_summaries:如果任何支线超过5章未被提及或推进→warning。如果存在支线但近3章完全没有任何支线推进→warning";
+    case 25:
+      return language === "en"
+        ? "Cross-check emotional_arcs and chapter_summaries: if a major character shows no emotional change for 3 straight chapters (no new pressure, release, or turn) -> warning. Distinguish unchanged circumstances from unchanged inner movement."
+        : "对照 emotional_arcs 和 chapter_summaries:如果主要角色连续3章情绪状态无变化(没有新的压力、释放、转变)→warning。注意区分'角色处境未变'和'角色内心未变'";
+    case 26:
+      return language === "en"
+        ? "Cross-check chapter_summaries for chapter-type distribution: 3+ consecutive chapters of the same type -> warning. No payoff or climax chapter for 5+ chapters -> warning. Explicitly list the recent type sequence."
+        : "对照 chapter_summaries 的章节类型分布:连续≥3章相同类型(如连续3个事件章/战斗章/布局章)→warning。≥5章没有出现回收章或高潮章→warning。请明确列出最近章节的类型序列";
+    case 28:
+      return language === "en"
+        ? "Check whether spinoff events contradict the mainline canon constraints."
+        : "检查番外事件是否与正典约束表矛盾";
+    case 29:
+      return language === "en"
+        ? "Check whether characters reference information that should only be revealed after the divergence point (see the information-boundary table)."
+        : "检查角色是否引用了分歧点之后才揭示的信息(参照信息边界表)";
+    case 30:
+      return language === "en"
+        ? "Check whether the spinoff violates mainline world rules (power system, geography, factions)."
+        : "检查番外是否违反正传世界规则(力量体系、地理、阵营)";
+    case 31:
+      return language === "en"
+        ? "Check whether the spinoff resolves mainline hooks without authorization (warning level)."
+        : "检查番外是否越权回收正传伏笔(warning级别)";
+    case 32:
+      return language === "en"
+        ? "Check: does the chapter ending provide a hook? Has there been a meaningful payoff within the last 3-5 chapters? Is emotional pressure being suppressed for more than 3 chapters without release? Are reader expectation gaps accumulating or being satisfied?"
+        : "检查:章尾是否有钩子?最近3-5章内是否有爽点落地?是否存在超过3章的情绪压制无释放?读者的情绪缺口是否在积累或被满足?";
+    case 33:
+      return language === "en"
+        ? "Cross-check volume_outline: does this chapter match the planned beat for the current chapter range? Did it skip planned nodes or consume later nodes too early? Does actual pacing match the planned chapter span? If a beat planned for N chapters is consumed in 1-2 chapters -> critical."
+        : "对照 volume_outline:本章内容是否对应卷纲中当前章节范围的剧情节点?是否跳过了节点或提前消耗了后续节点?剧情推进速度是否与卷纲规划的章节跨度匹配?如果卷纲规划某段剧情跨N章但实际1-2章就讲完→critical";
+    case 34:
+    case 35:
+    case 36:
+    case 37: {
+      if (!fanficConfig) return "";
+      const severity = fanficConfig.severityOverrides.get(id) ?? "warning";
+      const baseNote = language === "en"
+        ? {
+            34: "Check whether dialogue tics, speaking style, and behavior remain consistent with the character dossiers in fanfic_canon.md. Deviations need clear situational motivation.",
+            35: "Check whether the chapter violates world rules documented in fanfic_canon.md (geography, power system, faction relations).",
+            36: "Check whether relationship beats remain plausible and aligned with, or meaningfully develop from, the key relationships documented in fanfic_canon.md.",
+            37: "Check whether the chapter contradicts the key event timeline in fanfic_canon.md.",
+          }[id]
+        : FANFIC_DIMENSIONS.find((dimension) => dimension.id === id)?.baseNote;
+
+      return baseNote
+        ? `${baseNote} ${formatFanficSeverityNote(severity, language)}`
+        : "";
+    }
+    default:
+      return "";
+  }
+}
+
 function buildDimensionList(
 function buildDimensionList(
   gp: GenreProfile,
   gp: GenreProfile,
   bookRules: BookRules | null,
   bookRules: BookRules | null,
+  language: PromptLanguage,
   hasParentCanon = false,
   hasParentCanon = false,
   fanficMode?: FanficMode,
   fanficMode?: FanficMode,
 ): ReadonlyArray<{ readonly id: number; readonly name: string; readonly note: string }> {
 ): ReadonlyArray<{ readonly id: number; readonly name: string; readonly note: string }> {
@@ -81,8 +241,9 @@ function buildDimensionList(
   if (bookRules?.additionalAuditDimensions) {
   if (bookRules?.additionalAuditDimensions) {
     // Build reverse lookup: name → id
     // Build reverse lookup: name → id
     const nameToId = new Map<string, number>();
     const nameToId = new Map<string, number>();
-    for (const [id, name] of Object.entries(DIMENSION_MAP)) {
-      nameToId.set(name, Number(id));
+    for (const [id, labels] of Object.entries(DIMENSION_LABELS)) {
+      nameToId.set(labels.zh, Number(id));
+      nameToId.set(labels.en, Number(id));
     }
     }
 
 
     for (const d of bookRules.additionalAuditDimensions) {
     for (const d of bookRules.additionalAuditDimensions) {
@@ -138,59 +299,10 @@ function buildDimensionList(
   const dims: Array<{ id: number; name: string; note: string }> = [];
   const dims: Array<{ id: number; name: string; note: string }> = [];
 
 
   for (const id of [...activeIds].sort((a, b) => a - b)) {
   for (const id of [...activeIds].sort((a, b) => a - b)) {
-    const name = DIMENSION_MAP[id];
+    const name = dimensionName(id, language);
     if (!name) continue;
     if (!name) continue;
 
 
-    let note = "";
-    if (id === 10 && gp.fatigueWords.length > 0) {
-      const words = bookRules?.fatigueWordsOverride && bookRules.fatigueWordsOverride.length > 0
-        ? bookRules.fatigueWordsOverride
-        : gp.fatigueWords;
-      note = `高疲劳词:${words.join("、")}。同时检查AI标记词(仿佛/不禁/宛如/竟然/忽然/猛地)密度,每3000字超过1次即warning`;
-    }
-    if (id === 15 && gp.satisfactionTypes.length > 0) {
-      note = `爽点类型:${gp.satisfactionTypes.join("、")}`;
-    }
-    if (id === 12 && bookRules?.eraConstraints) {
-      const era = bookRules.eraConstraints;
-      const parts = [era.period, era.region].filter(Boolean);
-      if (parts.length > 0) note = `年代:${parts.join(",")}`;
-    }
-    if (id === 19) {
-      note = "检查视角切换是否有过渡、是否与设定视角一致";
-    }
-    if (id === 24) {
-      note = "对照 subplot_board 和 chapter_summaries:如果任何支线超过5章未被提及或推进→warning。如果存在支线但近3章完全没有任何支线推进→warning";
-    }
-    if (id === 25) {
-      note = "对照 emotional_arcs 和 chapter_summaries:如果主要角色连续3章情绪状态无变化(没有新的压力、释放、转变)→warning。注意区分'角色处境未变'和'角色内心未变'";
-    }
-    if (id === 26) {
-      note = "对照 chapter_summaries 的章节类型分布:连续≥3章相同类型(如连续3个事件章/战斗章/布局章)→warning。≥5章没有出现回收章或高潮章→warning。请明确列出最近章节的类型序列";
-    }
-    if (id === 28) {
-      note = "检查番外事件是否与正典约束表矛盾";
-    }
-    if (id === 29) {
-      note = "检查角色是否引用了分歧点之后才揭示的信息(参照信息边界表)";
-    }
-    if (id === 30) {
-      note = "检查番外是否违反正传世界规则(力量体系、地理、阵营)";
-    }
-    if (id === 31) {
-      note = "检查番外是否越权回收正传伏笔(warning级别)";
-    }
-    if (id === 32) {
-      note = "检查:章尾是否有钩子?最近3-5章内是否有爽点落地?是否存在超过3章的情绪压制无释放?读者的情绪缺口是否在积累或被满足?";
-    }
-    if (id === 33) {
-      note = "对照 volume_outline:本章内容是否对应卷纲中当前章节范围的剧情节点?是否跳过了节点或提前消耗了后续节点?剧情推进速度是否与卷纲规划的章节跨度匹配?如果卷纲规划某段剧情跨N章但实际1-2章就讲完→critical";
-    }
-
-    // Fanfic dimension notes (34-37) — mode-aware
-    if (fanficConfig?.notes.has(id)) {
-      note = fanficConfig.notes.get(id)!;
-    }
+    const note = buildDimensionNote(id, language, gp, bookRules, fanficMode, fanficConfig);
 
 
     dims.push({ id, name, note });
     dims.push({ id, name, note });
   }
   }
@@ -238,7 +350,10 @@ export class ContinuityAuditor extends BaseAgent {
 
 
     // Load genre profile and book rules
     // Load genre profile and book rules
     const genreId = genre ?? "other";
     const genreId = genre ?? "other";
-    const { profile: gp } = await readGenreProfile(this.ctx.projectRoot, genreId);
+    const [{ profile: gp }, bookLanguage] = await Promise.all([
+      readGenreProfile(this.ctx.projectRoot, genreId),
+      readBookLanguage(bookDir),
+    ]);
     const parsedRules = await readBookRules(bookDir);
     const parsedRules = await readBookRules(bookDir);
     const bookRules = parsedRules?.rules ?? null;
     const bookRules = parsedRules?.rules ?? null;
 
 
@@ -247,25 +362,29 @@ export class ContinuityAuditor extends BaseAgent {
       ? styleGuideRaw
       ? styleGuideRaw
       : (parsedRules?.body ?? "(无文风指南)");
       : (parsedRules?.body ?? "(无文风指南)");
 
 
+    const resolvedLanguage = bookLanguage ?? gp.language;
+    const isEnglish = resolvedLanguage === "en";
     const fanficMode = hasFanficCanon ? (bookRules?.fanficMode as FanficMode | undefined) : undefined;
     const fanficMode = hasFanficCanon ? (bookRules?.fanficMode as FanficMode | undefined) : undefined;
-    const dimensions = buildDimensionList(gp, bookRules, hasParentCanon, fanficMode);
+    const dimensions = buildDimensionList(gp, bookRules, resolvedLanguage, hasParentCanon, fanficMode);
     const dimList = dimensions
     const dimList = dimensions
-      .map((d) => `${d.id}. ${d.name}${d.note ? `(${d.note})` : ""}`)
+      .map((d) => `${d.id}. ${d.name}${d.note ? (isEnglish ? ` (${d.note})` : `(${d.note})`) : ""}`)
       .join("\n");
       .join("\n");
+    const genreLabel = resolveGenreLabel(genreId, gp.name, resolvedLanguage);
 
 
     const protagonistBlock = bookRules?.protagonist
     const protagonistBlock = bookRules?.protagonist
-      ? `\n主角人设锁定:${bookRules.protagonist.name},${bookRules.protagonist.personalityLock.join("、")},行为约束:${bookRules.protagonist.behavioralConstraints.join("、")}`
+      ? isEnglish
+        ? `\n\nProtagonist lock: ${bookRules.protagonist.name}; personality locks: ${joinLocalized(bookRules.protagonist.personalityLock, resolvedLanguage)}; behavioral constraints: ${joinLocalized(bookRules.protagonist.behavioralConstraints, resolvedLanguage)}.`
+        : `\n主角人设锁定:${bookRules.protagonist.name},${bookRules.protagonist.personalityLock.join("、")},行为约束:${bookRules.protagonist.behavioralConstraints.join("、")}`
       : "";
       : "";
 
 
     const searchNote = gp.eraResearch
     const searchNote = gp.eraResearch
-      ? "\n\n你有联网搜索能力(search_web / fetch_url)。对于涉及真实年代、人物、事件、地理、政策的内容,你必须用search_web核实,不可凭记忆判断。至少对比2个来源交叉验证。"
+      ? isEnglish
+        ? "\n\nYou have web-search capability (search_web / fetch_url). For real-world eras, people, events, geography, or policies, you must verify with search_web instead of relying on memory. Cross-check at least 2 sources."
+        : "\n\n你有联网搜索能力(search_web / fetch_url)。对于涉及真实年代、人物、事件、地理、政策的内容,你必须用search_web核实,不可凭记忆判断。至少对比2个来源交叉验证。"
       : "";
       : "";
 
 
-    const resolvedLanguage = gp.language;
-    const isEnglish = resolvedLanguage === "en";
-
     const systemPrompt = isEnglish
     const systemPrompt = isEnglish
-      ? `You are a strict ${gp.name} web fiction editor. Audit the chapter for continuity, consistency, and quality. ALL OUTPUT MUST BE IN ENGLISH.${protagonistBlock}${searchNote}
+      ? `You are a strict ${genreLabel} web fiction editor. Audit the chapter for continuity, consistency, and quality. ALL OUTPUT MUST BE IN ENGLISH.${protagonistBlock}${searchNote}
 
 
 Audit dimensions:
 Audit dimensions:
 ${dimList}
 ${dimList}
@@ -307,7 +426,9 @@ ${dimList}
 只有当存在 critical 级别问题时,passed 才为 false。`;
 只有当存在 critical 级别问题时,passed 才为 false。`;
 
 
     const ledgerBlock = gp.numericalSystem
     const ledgerBlock = gp.numericalSystem
-      ? `\n## 资源账本\n${ledger}`
+      ? isEnglish
+        ? `\n## Resource Ledger\n${ledger}`
+        : `\n## 资源账本\n${ledger}`
       : "";
       : "";
 
 
     // Smart context filtering for auditor — same logic as writer
     // Smart context filtering for auditor — same logic as writer
@@ -319,55 +440,86 @@ ${dimList}
     const filteredHooks = filterHooks(hooks);
     const filteredHooks = filterHooks(hooks);
 
 
     const governedMemoryBlocks = options?.contextPackage
     const governedMemoryBlocks = options?.contextPackage
-      ? buildGovernedMemoryEvidenceBlocks(options.contextPackage)
+      ? buildGovernedMemoryEvidenceBlocks(options.contextPackage, resolvedLanguage)
       : undefined;
       : undefined;
 
 
     const hooksBlock = governedMemoryBlocks?.hooksBlock
     const hooksBlock = governedMemoryBlocks?.hooksBlock
       ?? (filteredHooks !== "(文件不存在)"
       ?? (filteredHooks !== "(文件不存在)"
-        ? `\n## 伏笔池\n${filteredHooks}\n`
+        ? isEnglish
+          ? `\n## Pending Hooks\n${filteredHooks}\n`
+          : `\n## 伏笔池\n${filteredHooks}\n`
         : "");
         : "");
     const subplotBlock = filteredSubplots !== "(文件不存在)"
     const subplotBlock = filteredSubplots !== "(文件不存在)"
-      ? `\n## 支线进度板\n${filteredSubplots}\n`
+      ? isEnglish
+        ? `\n## Subplot Board\n${filteredSubplots}\n`
+        : `\n## 支线进度板\n${filteredSubplots}\n`
       : "";
       : "";
     const emotionalBlock = filteredArcs !== "(文件不存在)"
     const emotionalBlock = filteredArcs !== "(文件不存在)"
-      ? `\n## 情感弧线\n${filteredArcs}\n`
+      ? isEnglish
+        ? `\n## Emotional Arcs\n${filteredArcs}\n`
+        : `\n## 情感弧线\n${filteredArcs}\n`
       : "";
       : "";
     const matrixBlock = filteredMatrix !== "(文件不存在)"
     const matrixBlock = filteredMatrix !== "(文件不存在)"
-      ? `\n## 角色交互矩阵\n${filteredMatrix}\n`
+      ? isEnglish
+        ? `\n## Character Interaction Matrix\n${filteredMatrix}\n`
+        : `\n## 角色交互矩阵\n${filteredMatrix}\n`
       : "";
       : "";
     const summariesBlock = governedMemoryBlocks?.summariesBlock
     const summariesBlock = governedMemoryBlocks?.summariesBlock
       ?? (filteredSummaries !== "(文件不存在)"
       ?? (filteredSummaries !== "(文件不存在)"
-        ? `\n## 章节摘要(用于节奏检查)\n${filteredSummaries}\n`
+        ? isEnglish
+          ? `\n## Chapter Summaries (for pacing checks)\n${filteredSummaries}\n`
+          : `\n## 章节摘要(用于节奏检查)\n${filteredSummaries}\n`
         : "");
         : "");
+    const volumeSummariesBlock = governedMemoryBlocks?.volumeSummariesBlock ?? "";
 
 
     const canonBlock = hasParentCanon
     const canonBlock = hasParentCanon
-      ? `\n## 正传正典参照(番外审查专用)\n${parentCanon}\n`
+      ? isEnglish
+        ? `\n## Mainline Canon Reference (for spinoff audit)\n${parentCanon}\n`
+        : `\n## 正传正典参照(番外审查专用)\n${parentCanon}\n`
       : "";
       : "";
 
 
     const fanficCanonBlock = hasFanficCanon
     const fanficCanonBlock = hasFanficCanon
-      ? `\n## 同人正典参照(同人审查专用)\n${fanficCanon}\n`
+      ? isEnglish
+        ? `\n## Fanfic Canon Reference (for fanfic audit)\n${fanficCanon}\n`
+        : `\n## 同人正典参照(同人审查专用)\n${fanficCanon}\n`
       : "";
       : "";
 
 
     const outlineBlock = volumeOutline !== "(文件不存在)"
     const outlineBlock = volumeOutline !== "(文件不存在)"
-      ? `\n## 卷纲(用于大纲偏离检测)\n${volumeOutline}\n`
+      ? isEnglish
+        ? `\n## Volume Outline (for outline drift checks)\n${volumeOutline}\n`
+        : `\n## 卷纲(用于大纲偏离检测)\n${volumeOutline}\n`
       : "";
       : "";
     const reducedControlBlock = options?.chapterIntent && options.contextPackage && options.ruleStack
     const reducedControlBlock = options?.chapterIntent && options.contextPackage && options.ruleStack
-      ? this.buildReducedControlBlock(options.chapterIntent, options.contextPackage, options.ruleStack)
+      ? this.buildReducedControlBlock(options.chapterIntent, options.contextPackage, options.ruleStack, resolvedLanguage)
       : "";
       : "";
     const styleGuideBlock = reducedControlBlock.length === 0
     const styleGuideBlock = reducedControlBlock.length === 0
-      ? `\n## 文风指南\n${styleGuide}`
+      ? isEnglish
+        ? `\n## Style Guide\n${styleGuide}`
+        : `\n## 文风指南\n${styleGuide}`
       : "";
       : "";
 
 
     const prevChapterBlock = previousChapter
     const prevChapterBlock = previousChapter
-      ? `\n## 上一章全文(用于衔接检查)\n${previousChapter}\n`
+      ? isEnglish
+        ? `\n## Previous Chapter Full Text (for transition checks)\n${previousChapter}\n`
+        : `\n## 上一章全文(用于衔接检查)\n${previousChapter}\n`
       : "";
       : "";
 
 
-    const userPrompt = `请审查第${chapterNumber}章。
+    const userPrompt = isEnglish
+      ? `Review chapter ${chapterNumber}.
+
+## Current State Card
+${currentState}
+${ledgerBlock}
+${hooksBlock}${volumeSummariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${reducedControlBlock || outlineBlock}${prevChapterBlock}${styleGuideBlock}
+
+## Chapter Content Under Review
+${chapterContent}`
+      : `请审查第${chapterNumber}章。
 
 
 ## 当前状态卡
 ## 当前状态卡
 ${currentState}
 ${currentState}
 ${ledgerBlock}
 ${ledgerBlock}
-${hooksBlock}${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${reducedControlBlock || outlineBlock}${prevChapterBlock}${styleGuideBlock}
+${hooksBlock}${volumeSummariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${reducedControlBlock || outlineBlock}${prevChapterBlock}${styleGuideBlock}
 
 
 ## 待审章节内容
 ## 待审章节内容
 ${chapterContent}`;
 ${chapterContent}`;
@@ -383,31 +535,31 @@ ${chapterContent}`;
       ? await this.chatWithSearch(chatMessages, chatOptions)
       ? await this.chatWithSearch(chatMessages, chatOptions)
       : await this.chat(chatMessages, chatOptions);
       : await this.chat(chatMessages, chatOptions);
 
 
-    const result = this.parseAuditResult(response.content);
+    const result = this.parseAuditResult(response.content, resolvedLanguage);
     return { ...result, tokenUsage: response.usage };
     return { ...result, tokenUsage: response.usage };
   }
   }
 
 
-  private parseAuditResult(content: string): AuditResult {
+  private parseAuditResult(content: string, language: PromptLanguage): AuditResult {
     // Try multiple JSON extraction strategies (handles small/local models)
     // Try multiple JSON extraction strategies (handles small/local models)
 
 
     // Strategy 1: Find balanced JSON object (not greedy)
     // Strategy 1: Find balanced JSON object (not greedy)
     const balanced = this.extractBalancedJson(content);
     const balanced = this.extractBalancedJson(content);
     if (balanced) {
     if (balanced) {
-      const result = this.tryParseAuditJson(balanced);
+      const result = this.tryParseAuditJson(balanced, language);
       if (result) return result;
       if (result) return result;
     }
     }
 
 
     // Strategy 2: Try the whole content as JSON (some models output pure JSON)
     // Strategy 2: Try the whole content as JSON (some models output pure JSON)
     const trimmed = content.trim();
     const trimmed = content.trim();
     if (trimmed.startsWith("{")) {
     if (trimmed.startsWith("{")) {
-      const result = this.tryParseAuditJson(trimmed);
+      const result = this.tryParseAuditJson(trimmed, language);
       if (result) return result;
       if (result) return result;
     }
     }
 
 
     // Strategy 3: Look for ```json code blocks
     // Strategy 3: Look for ```json code blocks
     const codeBlockMatch = content.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
     const codeBlockMatch = content.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
     if (codeBlockMatch) {
     if (codeBlockMatch) {
-      const result = this.tryParseAuditJson(codeBlockMatch[1]!.trim());
+      const result = this.tryParseAuditJson(codeBlockMatch[1]!.trim(), language);
       if (result) return result;
       if (result) return result;
     }
     }
 
 
@@ -426,7 +578,7 @@ ${chapterContent}`;
             const issue = JSON.parse(match[0]);
             const issue = JSON.parse(match[0]);
             issues.push({
             issues.push({
               severity: issue.severity ?? "warning",
               severity: issue.severity ?? "warning",
-              category: issue.category ?? "未分类",
+              category: issue.category ?? (language === "en" ? "Uncategorized" : "未分类"),
               description: issue.description ?? "",
               description: issue.description ?? "",
               suggestion: issue.suggestion ?? "",
               suggestion: issue.suggestion ?? "",
             });
             });
@@ -446,11 +598,15 @@ ${chapterContent}`;
       passed: false,
       passed: false,
       issues: [{
       issues: [{
         severity: "critical",
         severity: "critical",
-        category: "系统错误",
-        description: "审稿输出格式异常,无法解析为 JSON",
-        suggestion: "可能是模型不支持结构化输出。尝试换一个更大的模型,或检查 API 返回格式。",
+        category: language === "en" ? "System Error" : "系统错误",
+        description: language === "en"
+          ? "Audit output format was invalid and could not be parsed as JSON."
+          : "审稿输出格式异常,无法解析为 JSON",
+        suggestion: language === "en"
+          ? "The model may not support reliable structured output. Try a stronger model or inspect the API response format."
+          : "可能是模型不支持结构化输出。尝试换一个更大的模型,或检查 API 返回格式。",
       }],
       }],
-      summary: "审稿输出解析失败",
+      summary: language === "en" ? "Audit output parsing failed" : "审稿输出解析失败",
     };
     };
   }
   }
 
 
@@ -458,6 +614,7 @@ ${chapterContent}`;
     chapterIntent: string,
     chapterIntent: string,
     contextPackage: ContextPackage,
     contextPackage: ContextPackage,
     ruleStack: RuleStack,
     ruleStack: RuleStack,
+    language: PromptLanguage,
   ): string {
   ): string {
     const selectedContext = contextPackage.selectedContext
     const selectedContext = contextPackage.selectedContext
       .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`)
       .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`)
@@ -468,7 +625,21 @@ ${chapterContent}`;
         .join("\n")
         .join("\n")
       : "- none";
       : "- none";
 
 
-    return `\n## 本章控制输入(由 Planner/Composer 编译)
+    return language === "en"
+      ? `\n## Chapter Control Inputs (compiled by Planner/Composer)
+${chapterIntent}
+
+### Selected Context
+${selectedContext || "- none"}
+
+### Rule Stack
+- Hard guardrails: ${ruleStack.sections.hard.join(", ") || "(none)"}
+- Soft constraints: ${ruleStack.sections.soft.join(", ") || "(none)"}
+- Diagnostic rules: ${ruleStack.sections.diagnostic.join(", ") || "(none)"}
+
+### Active Overrides
+${overrides}\n`
+      : `\n## 本章控制输入(由 Planner/Composer 编译)
 ${chapterIntent}
 ${chapterIntent}
 
 
 ### 已选上下文
 ### 已选上下文
@@ -495,7 +666,7 @@ ${overrides}\n`;
     return null;
     return null;
   }
   }
 
 
-  private tryParseAuditJson(json: string): AuditResult | null {
+  private tryParseAuditJson(json: string, language: PromptLanguage = "zh"): AuditResult | null {
     try {
     try {
       const parsed = JSON.parse(json);
       const parsed = JSON.parse(json);
       if (typeof parsed.passed !== "boolean" && parsed.passed !== undefined) return null;
       if (typeof parsed.passed !== "boolean" && parsed.passed !== undefined) return null;
@@ -504,7 +675,7 @@ ${overrides}\n`;
         issues: Array.isArray(parsed.issues)
         issues: Array.isArray(parsed.issues)
           ? parsed.issues.map((i: Record<string, unknown>) => ({
           ? parsed.issues.map((i: Record<string, unknown>) => ({
               severity: (i.severity as string) ?? "warning",
               severity: (i.severity as string) ?? "warning",
-              category: (i.category as string) ?? "未分类",
+              category: (i.category as string) ?? (language === "en" ? "Uncategorized" : "未分类"),
               description: (i.description as string) ?? "",
               description: (i.description as string) ?? "",
               suggestion: (i.suggestion as string) ?? "",
               suggestion: (i.suggestion as string) ?? "",
             }))
             }))

+ 138 - 15
packages/core/src/agents/planner.ts

@@ -55,8 +55,8 @@ export class PlannerAgent extends BaseAgent {
       this.readFileOrDefault(sourcePaths.currentState),
       this.readFileOrDefault(sourcePaths.currentState),
     ]);
     ]);
 
 
-    const goal = this.deriveGoal(input.externalContext, currentFocus, authorIntent, input.chapterNumber);
     const outlineNode = this.findOutlineNode(volumeOutline, input.chapterNumber);
     const outlineNode = this.findOutlineNode(volumeOutline, input.chapterNumber);
+    const goal = this.deriveGoal(input.externalContext, currentFocus, authorIntent, outlineNode, input.chapterNumber);
     const parsedRules = parseBookRules(bookRulesRaw);
     const parsedRules = parseBookRules(bookRulesRaw);
     const mustKeep = this.collectMustKeep(currentState, storyBible);
     const mustKeep = this.collectMustKeep(currentState, storyBible);
     const mustAvoid = this.collectMustAvoid(currentFocus, parsedRules.rules.prohibitions);
     const mustAvoid = this.collectMustAvoid(currentFocus, parsedRules.rules.prohibitions);
@@ -84,8 +84,8 @@ export class PlannerAgent extends BaseAgent {
     const runtimePath = join(runtimeDir, `chapter-${String(input.chapterNumber).padStart(4, "0")}.intent.md`);
     const runtimePath = join(runtimeDir, `chapter-${String(input.chapterNumber).padStart(4, "0")}.intent.md`);
     const intentMarkdown = this.renderIntentMarkdown(
     const intentMarkdown = this.renderIntentMarkdown(
       intent,
       intent,
-      renderHookSnapshot(memorySelection.hooks),
-      renderSummarySnapshot(memorySelection.summaries),
+      renderHookSnapshot(memorySelection.hooks, input.book.language ?? "zh"),
+      renderSummarySnapshot(memorySelection.summaries, input.book.language ?? "zh"),
     );
     );
     await writeFile(runtimePath, intentMarkdown, "utf-8");
     await writeFile(runtimePath, intentMarkdown, "utf-8");
 
 
@@ -106,12 +106,15 @@ export class PlannerAgent extends BaseAgent {
     externalContext: string | undefined,
     externalContext: string | undefined,
     currentFocus: string,
     currentFocus: string,
     authorIntent: string,
     authorIntent: string,
+    outlineNode: string | undefined,
     chapterNumber: number,
     chapterNumber: number,
   ): string {
   ): string {
     const first = this.extractFirstDirective(externalContext);
     const first = this.extractFirstDirective(externalContext);
     if (first) return first;
     if (first) return first;
-    const focus = this.extractFirstDirective(currentFocus);
+    const focus = this.extractFocusGoal(currentFocus);
     if (focus) return focus;
     if (focus) return focus;
+    const outline = this.extractFirstDirective(outlineNode);
+    if (outline) return outline;
     const author = this.extractFirstDirective(authorIntent);
     const author = this.extractFirstDirective(authorIntent);
     if (author) return author;
     if (author) return author;
     return `Advance chapter ${chapterNumber} with clear narrative focus.`;
     return `Advance chapter ${chapterNumber} with clear narrative focus.`;
@@ -125,21 +128,31 @@ export class PlannerAgent extends BaseAgent {
   }
   }
 
 
   private collectMustAvoid(currentFocus: string, prohibitions: ReadonlyArray<string>): string[] {
   private collectMustAvoid(currentFocus: string, prohibitions: ReadonlyArray<string>): string[] {
-    const focusAvoids = currentFocus
-      .split("\n")
-      .map((line) => line.trim())
-      .filter((line) =>
-        line.startsWith("-") &&
-        /avoid|don't|do not|不要|别|禁止/i.test(line),
-      )
-      .map((line) => line.replace(/^-\s*/, ""));
+    const avoidSection = this.extractSection(currentFocus, [
+      "avoid",
+      "must avoid",
+      "禁止",
+      "避免",
+      "避雷",
+    ]);
+    const focusAvoids = avoidSection
+      ? this.extractListItems(avoidSection, 10)
+      : currentFocus
+        .split("\n")
+        .map((line) => line.trim())
+        .filter((line) =>
+          line.startsWith("-") &&
+          /avoid|don't|do not|不要|别|禁止/i.test(line),
+        )
+        .map((line) => this.cleanListItem(line))
+        .filter((line): line is string => Boolean(line));
 
 
     return this.unique([...focusAvoids, ...prohibitions]).slice(0, 6);
     return this.unique([...focusAvoids, ...prohibitions]).slice(0, 6);
   }
   }
 
 
   private collectStyleEmphasis(authorIntent: string, currentFocus: string): string[] {
   private collectStyleEmphasis(authorIntent: string, currentFocus: string): string[] {
     return this.unique([
     return this.unique([
-      ...this.extractListItems(currentFocus, 2),
+      ...this.extractFocusStyleItems(currentFocus),
       ...this.extractListItems(authorIntent, 2),
       ...this.extractListItems(authorIntent, 2),
     ]).slice(0, 4);
     ]).slice(0, 4);
   }
   }
@@ -168,7 +181,12 @@ export class PlannerAgent extends BaseAgent {
     return content
     return content
       .split("\n")
       .split("\n")
       .map((line) => line.trim())
       .map((line) => line.trim())
-      .find((line) => line.length > 0 && !line.startsWith("#") && !line.startsWith("-"));
+      .find((line) =>
+        line.length > 0
+        && !line.startsWith("#")
+        && !line.startsWith("-")
+        && !this.isTemplatePlaceholder(line),
+      );
   }
   }
 
 
   private extractListItems(content: string, limit: number): string[] {
   private extractListItems(content: string, limit: number): string[] {
@@ -176,16 +194,121 @@ export class PlannerAgent extends BaseAgent {
       .split("\n")
       .split("\n")
       .map((line) => line.trim())
       .map((line) => line.trim())
       .filter((line) => line.startsWith("-"))
       .filter((line) => line.startsWith("-"))
-      .map((line) => line.replace(/^-\s*/, ""))
+      .map((line) => this.cleanListItem(line))
+      .filter((line): line is string => Boolean(line))
       .slice(0, limit);
       .slice(0, limit);
   }
   }
 
 
+  private extractFocusGoal(currentFocus: string): string | undefined {
+    const focusSection = this.extractSection(currentFocus, [
+      "active focus",
+      "focus",
+      "当前聚焦",
+      "当前焦点",
+      "近期聚焦",
+    ]) ?? currentFocus;
+    const directives = this.extractFocusStyleItems(focusSection, 3);
+    if (directives.length === 0) {
+      return this.extractFirstDirective(focusSection);
+    }
+    return directives.join(this.containsChinese(focusSection) ? ";" : "; ");
+  }
+
+  private extractFocusStyleItems(currentFocus: string, limit = 3): string[] {
+    const focusSection = this.extractSection(currentFocus, [
+      "active focus",
+      "focus",
+      "当前聚焦",
+      "当前焦点",
+      "近期聚焦",
+    ]) ?? currentFocus;
+    return this.extractListItems(focusSection, limit);
+  }
+
+  private extractSection(content: string, headings: ReadonlyArray<string>): string | undefined {
+    const targets = headings.map((heading) => this.normalizeHeading(heading));
+    const lines = content.split("\n");
+    let buffer: string[] | null = null;
+    let sectionLevel = 0;
+
+    for (const line of lines) {
+      const headingMatch = line.match(/^(#+)\s*(.+?)\s*$/);
+      if (headingMatch) {
+        const level = headingMatch[1]!.length;
+        const heading = this.normalizeHeading(headingMatch[2]!);
+
+        if (buffer && level <= sectionLevel) {
+          break;
+        }
+
+        if (targets.includes(heading)) {
+          buffer = [];
+          sectionLevel = level;
+          continue;
+        }
+      }
+
+      if (buffer) {
+        buffer.push(line);
+      }
+    }
+
+    const section = buffer?.join("\n").trim();
+    return section && section.length > 0 ? section : undefined;
+  }
+
+  private normalizeHeading(heading: string): string {
+    return heading
+      .toLowerCase()
+      .replace(/[*_`:#]/g, "")
+      .replace(/\s+/g, " ")
+      .trim();
+  }
+
+  private cleanListItem(line: string): string | undefined {
+    const cleaned = line.replace(/^-\s*/, "").trim();
+    if (cleaned.length === 0) return undefined;
+    if (/^[-|]+$/.test(cleaned)) return undefined;
+    if (this.isTemplatePlaceholder(cleaned)) return undefined;
+    return cleaned;
+  }
+
+  private isTemplatePlaceholder(line: string): boolean {
+    const normalized = line.trim();
+    if (!normalized) return false;
+
+    return (
+      /^\((describe|briefly describe|write)\b[\s\S]*\)$/i.test(normalized)
+      || /^((?:在这里描述|描述|填写|写下)[\s\S]*)$/u.test(normalized)
+    );
+  }
+
+  private containsChinese(content: string): boolean {
+    return /[\u4e00-\u9fff]/.test(content);
+  }
+
   private findOutlineNode(volumeOutline: string, chapterNumber: number): string | undefined {
   private findOutlineNode(volumeOutline: string, chapterNumber: number): string | undefined {
     const lines = volumeOutline.split("\n").map((line) => line.trim()).filter(Boolean);
     const lines = volumeOutline.split("\n").map((line) => line.trim()).filter(Boolean);
     const chapterPatterns = [
     const chapterPatterns = [
       new RegExp(`^#+\\s*Chapter\\s*${chapterNumber}\\b`, "i"),
       new RegExp(`^#+\\s*Chapter\\s*${chapterNumber}\\b`, "i"),
       new RegExp(`^#+\\s*第\\s*${chapterNumber}\\s*章`),
       new RegExp(`^#+\\s*第\\s*${chapterNumber}\\s*章`),
     ];
     ];
+    const inlinePatterns = [
+      new RegExp(`^(?:[-*]\\s+)?(?:\\*\\*)?Chapter\\s*${chapterNumber}(?:[::-])?(?:\\*\\*)?\\s*(.+)$`, "i"),
+      new RegExp(`^(?:[-*]\\s+)?(?:\\*\\*)?第\\s*${chapterNumber}\\s*章(?:[::-])?(?:\\*\\*)?\\s*(.+)$`),
+    ];
+
+    const inlineMatch = lines
+      .map((line) => ({
+        line,
+        match: inlinePatterns
+          .map((pattern) => line.match(pattern))
+          .find((result): result is RegExpMatchArray => Boolean(result?.[1]?.trim())),
+      }))
+      .find((entry) => entry.match);
+    if (inlineMatch?.match?.[1]) {
+      return inlineMatch.match[1].trim();
+    }
 
 
     const heading = lines.find((line) => chapterPatterns.some((pattern) => pattern.test(line)));
     const heading = lines.find((line) => chapterPatterns.some((pattern) => pattern.test(line)));
     if (!heading) return this.extractFirstDirective(volumeOutline);
     if (!heading) return this.extractFirstDirective(volumeOutline);

+ 121 - 35
packages/core/src/agents/reviser.ts

@@ -4,14 +4,23 @@ import type { BookRules } from "../models/book-rules.js";
 import type { LengthSpec } from "../models/length-governance.js";
 import type { LengthSpec } from "../models/length-governance.js";
 import type { AuditIssue } from "./continuity.js";
 import type { AuditIssue } from "./continuity.js";
 import type { ContextPackage, RuleStack } from "../models/input-governance.js";
 import type { ContextPackage, RuleStack } from "../models/input-governance.js";
-import { readGenreProfile, readBookRules } from "./rules-reader.js";
+import { readGenreProfile, readBookLanguage, readBookRules } from "./rules-reader.js";
 import { countChapterLength } from "../utils/length-metrics.js";
 import { countChapterLength } from "../utils/length-metrics.js";
 import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
 import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
+import { filterSummaries } from "../utils/context-filter.js";
+import {
+  buildGovernedCharacterMatrixWorkingSet,
+  buildGovernedHookWorkingSet,
+  mergeTableMarkdownByKey,
+} from "../utils/governed-working-set.js";
+import { applySpotFixPatches, parseSpotFixPatches } from "../utils/spot-fix-patches.js";
 import { readFile } from "node:fs/promises";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 
 
 export type ReviseMode = "polish" | "rewrite" | "rework" | "anti-detect" | "spot-fix";
 export type ReviseMode = "polish" | "rewrite" | "rework" | "anti-detect" | "spot-fix";
 
 
+export const DEFAULT_REVISE_MODE: ReviseMode = "spot-fix";
+
 export interface ReviseOutput {
 export interface ReviseOutput {
   readonly revisedContent: string;
   readonly revisedContent: string;
   readonly wordCount: number;
   readonly wordCount: number;
@@ -28,7 +37,7 @@ export interface ReviseOutput {
 
 
 const MODE_DESCRIPTIONS: Record<ReviseMode, string> = {
 const MODE_DESCRIPTIONS: Record<ReviseMode, string> = {
   polish: "润色:只改表达、节奏、段落呼吸,不改事实与剧情结论。禁止:增删段落、改变人名/地名/物品名、增加新情节或新对话、改变因果关系。只允许:替换用词、调整句序、修改标点节奏",
   polish: "润色:只改表达、节奏、段落呼吸,不改事实与剧情结论。禁止:增删段落、改变人名/地名/物品名、增加新情节或新对话、改变因果关系。只允许:替换用词、调整句序、修改标点节奏",
-  rewrite: "改写:可改叙述顺序、画面、力度,但保留核心事实与人物动机",
+  rewrite: "改写:允许重组问题段落、调整画面和叙述力度,但优先保留原文的绝大部分句段。除非问题跨越整章,否则禁止整章推倒重写;只能围绕问题段落及其直接上下文改写,同时保留核心事实与人物动机",
   rework: "重写:可重构场景推进和冲突组织,但不改主设定和大事件结果",
   rework: "重写:可重构场景推进和冲突组织,但不改主设定和大事件结果",
   "anti-detect": `反检测改写:在保持剧情不变的前提下,降低AI生成可检测性。
   "anti-detect": `反检测改写:在保持剧情不变的前提下,降低AI生成可检测性。
 
 
@@ -55,7 +64,7 @@ export class ReviserAgent extends BaseAgent {
     chapterContent: string,
     chapterContent: string,
     chapterNumber: number,
     chapterNumber: number,
     issues: ReadonlyArray<AuditIssue>,
     issues: ReadonlyArray<AuditIssue>,
-    mode: ReviseMode = "rewrite",
+    mode: ReviseMode = DEFAULT_REVISE_MODE,
     genre?: string,
     genre?: string,
     options?: {
     options?: {
       chapterIntent?: string;
       chapterIntent?: string;
@@ -79,7 +88,10 @@ export class ReviserAgent extends BaseAgent {
 
 
     // Load genre profile and book rules
     // Load genre profile and book rules
     const genreId = genre ?? "other";
     const genreId = genre ?? "other";
-    const { profile: gp } = await readGenreProfile(this.ctx.projectRoot, genreId);
+    const [{ profile: gp }, bookLanguage] = await Promise.all([
+      readGenreProfile(this.ctx.projectRoot, genreId),
+      readBookLanguage(bookDir),
+    ]);
     const parsedRules = await readBookRules(bookDir);
     const parsedRules = await readBookRules(bookDir);
     const bookRules = parsedRules?.rules ?? null;
     const bookRules = parsedRules?.rules ?? null;
 
 
@@ -103,10 +115,63 @@ export class ReviserAgent extends BaseAgent {
       ? `\n8. 保持章节字数在目标区间内;只有在修复关键问题确实需要时才允许轻微偏离`
       ? `\n8. 保持章节字数在目标区间内;只有在修复关键问题确实需要时才允许轻微偏离`
       : "";
       : "";
 
 
-    const isEnglish = gp.language === "en";
+    const isEnglish = (bookLanguage ?? gp.language) === "en";
+    const resolvedLanguage = isEnglish ? "en" : "zh";
     const langPrefix = isEnglish
     const langPrefix = isEnglish
-      ? `【LANGUAGE OVERRIDE】ALL output (FIXED_ISSUES, REVISED_CONTENT, UPDATED_STATE, UPDATED_HOOKS) MUST be in English. The revised chapter content must be written entirely in English.\n\n`
+      ? mode === "spot-fix"
+        ? `【LANGUAGE OVERRIDE】ALL output (FIXED_ISSUES, PATCHES, UPDATED_STATE, UPDATED_HOOKS) MUST be in English. Every TARGET_TEXT and REPLACEMENT_TEXT must be written entirely in English.\n\n`
+        : `【LANGUAGE OVERRIDE】ALL output (FIXED_ISSUES, REVISED_CONTENT, UPDATED_STATE, UPDATED_HOOKS) MUST be in English. The revised chapter content must be written entirely in English.\n\n`
       : "";
       : "";
+    const governedMode = Boolean(options?.chapterIntent && options?.contextPackage && options?.ruleStack);
+    const hooksWorkingSet = governedMode && options?.contextPackage
+      ? buildGovernedHookWorkingSet({
+          hooksMarkdown: hooks,
+          contextPackage: options.contextPackage,
+          chapterNumber,
+          language: resolvedLanguage,
+        })
+      : hooks;
+    const chapterSummariesWorkingSet = governedMode
+      ? filterSummaries(chapterSummaries, chapterNumber)
+      : chapterSummaries;
+    const characterMatrixWorkingSet = governedMode
+      ? buildGovernedCharacterMatrixWorkingSet({
+          matrixMarkdown: characterMatrix,
+          chapterIntent: options?.chapterIntent ?? volumeOutline,
+          contextPackage: options!.contextPackage!,
+          protagonistName: bookRules?.protagonist?.name,
+        })
+      : characterMatrix;
+
+    const outputFormat = mode === "spot-fix"
+      ? `=== FIXED_ISSUES ===
+(逐条说明修正了什么,一行一条;如果无法安全定点修复,也在这里说明)
+
+=== PATCHES ===
+(只输出需要替换的局部补丁,不得输出整章重写。格式如下,可重复多个 PATCH 区块)
+--- PATCH 1 ---
+TARGET_TEXT:
+(必须从原文中精确复制、且能唯一命中的原句或原段)
+REPLACEMENT_TEXT:
+(替换后的局部文本)
+--- END PATCH ---
+
+=== UPDATED_STATE ===
+(更新后的完整状态卡)
+${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账本)" : ""}
+=== UPDATED_HOOKS ===
+(更新后的完整伏笔池)`
+      : `=== FIXED_ISSUES ===
+(逐条说明修正了什么,一行一条)
+
+=== REVISED_CONTENT ===
+(修正后的完整正文)
+
+=== UPDATED_STATE ===
+(更新后的完整状态卡)
+${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账本)" : ""}
+=== UPDATED_HOOKS ===
+(更新后的完整伏笔池)`;
 
 
     const systemPrompt = `${langPrefix}你是一位专业的${gp.name}网络小说修稿编辑。你的任务是根据审稿意见对章节进行修正。${protagonistBlock}
     const systemPrompt = `${langPrefix}你是一位专业的${gp.name}网络小说修稿编辑。你的任务是根据审稿意见对章节进行修正。${protagonistBlock}
 
 
@@ -120,42 +185,34 @@ export class ReviserAgent extends BaseAgent {
 6. 保持原文的语言风格和节奏
 6. 保持原文的语言风格和节奏
 7. 修改后同步更新状态卡${gp.numericalSystem ? "、账本" : ""}、伏笔池
 7. 修改后同步更新状态卡${gp.numericalSystem ? "、账本" : ""}、伏笔池
 ${lengthGuardrail}
 ${lengthGuardrail}
+${mode === "spot-fix" ? "\n9. spot-fix 只能输出局部补丁,禁止输出整章改写;TARGET_TEXT 必须能在原文中唯一命中\n10. 如果需要大面积改写,说明无法安全 spot-fix,并让 PATCHES 留空" : ""}
 
 
 输出格式:
 输出格式:
 
 
-=== FIXED_ISSUES ===
-(逐条说明修正了什么,一行一条)
-
-=== REVISED_CONTENT ===
-(修正后的完整正文)
-
-=== UPDATED_STATE ===
-(更新后的完整状态卡)
-${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账本)" : ""}
-=== UPDATED_HOOKS ===
-(更新后的完整伏笔池)`;
+${outputFormat}`;
 
 
     const ledgerBlock = gp.numericalSystem
     const ledgerBlock = gp.numericalSystem
       ? `\n## 资源账本\n${ledger}`
       ? `\n## 资源账本\n${ledger}`
       : "";
       : "";
     const governedMemoryBlocks = options?.contextPackage
     const governedMemoryBlocks = options?.contextPackage
-      ? buildGovernedMemoryEvidenceBlocks(options.contextPackage)
+      ? buildGovernedMemoryEvidenceBlocks(options.contextPackage, resolvedLanguage)
       : undefined;
       : undefined;
     const hooksBlock = governedMemoryBlocks?.hooksBlock
     const hooksBlock = governedMemoryBlocks?.hooksBlock
-      ?? `\n## 伏笔池\n${hooks}\n`;
+      ?? `\n## 伏笔池\n${hooksWorkingSet}\n`;
     const outlineBlock = volumeOutline !== "(文件不存在)"
     const outlineBlock = volumeOutline !== "(文件不存在)"
       ? `\n## 卷纲\n${volumeOutline}\n`
       ? `\n## 卷纲\n${volumeOutline}\n`
       : "";
       : "";
-    const bibleBlock = storyBible !== "(文件不存在)"
+    const bibleBlock = !governedMode && storyBible !== "(文件不存在)"
       ? `\n## 世界观设定\n${storyBible}\n`
       ? `\n## 世界观设定\n${storyBible}\n`
       : "";
       : "";
-    const matrixBlock = characterMatrix !== "(文件不存在)"
-      ? `\n## 角色交互矩阵\n${characterMatrix}\n`
+    const matrixBlock = characterMatrixWorkingSet !== "(文件不存在)"
+      ? `\n## 角色交互矩阵\n${characterMatrixWorkingSet}\n`
       : "";
       : "";
     const summariesBlock = governedMemoryBlocks?.summariesBlock
     const summariesBlock = governedMemoryBlocks?.summariesBlock
-      ?? (chapterSummaries !== "(文件不存在)"
-        ? `\n## 章节摘要\n${chapterSummaries}\n`
+      ?? (chapterSummariesWorkingSet !== "(文件不存在)"
+        ? `\n## 章节摘要\n${chapterSummariesWorkingSet}\n`
         : "");
         : "");
+    const volumeSummariesBlock = governedMemoryBlocks?.volumeSummariesBlock ?? "";
 
 
     const hasParentCanon = parentCanon !== "(文件不存在)";
     const hasParentCanon = parentCanon !== "(文件不存在)";
     const hasFanficCanon = fanficCanon !== "(文件不存在)";
     const hasFanficCanon = fanficCanon !== "(文件不存在)";
@@ -185,7 +242,7 @@ ${issueList}
 ## 当前状态卡
 ## 当前状态卡
 ${currentState}
 ${currentState}
 ${ledgerBlock}
 ${ledgerBlock}
-${hooksBlock}${reducedControlBlock || outlineBlock}${bibleBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${styleGuideBlock}${lengthGuidanceBlock}
+${hooksBlock}${volumeSummariesBlock}${reducedControlBlock || outlineBlock}${bibleBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${styleGuideBlock}${lengthGuidanceBlock}
 
 
 ## 待修正章节
 ## 待修正章节
 ${chapterContent}`;
 ${chapterContent}`;
@@ -200,14 +257,25 @@ ${chapterContent}`;
       { temperature: 0.3, maxTokens },
       { temperature: 0.3, maxTokens },
     );
     );
 
 
-    const output = this.parseOutput(response.content, gp);
+    const output = this.parseOutput(response.content, gp, mode, chapterContent);
+    const mergedOutput = governedMode
+      ? {
+          ...output,
+          updatedHooks: mergeTableMarkdownByKey(hooks, output.updatedHooks, [0]),
+        }
+      : output;
     const wordCount = options?.lengthSpec
     const wordCount = options?.lengthSpec
-      ? countChapterLength(output.revisedContent, options.lengthSpec.countingMode)
-      : output.wordCount;
-    return { ...output, wordCount, tokenUsage: response.usage };
+      ? countChapterLength(mergedOutput.revisedContent, options.lengthSpec.countingMode)
+      : mergedOutput.wordCount;
+    return { ...mergedOutput, wordCount, tokenUsage: response.usage };
   }
   }
 
 
-  private parseOutput(content: string, gp: GenreProfile): ReviseOutput {
+  private parseOutput(
+    content: string,
+    gp: GenreProfile,
+    mode: ReviseMode,
+    originalChapter: string,
+  ): ReviseOutput {
     const extract = (tag: string): string => {
     const extract = (tag: string): string => {
       const regex = new RegExp(
       const regex = new RegExp(
         `=== ${tag} ===\\s*([\\s\\S]*?)(?==== [A-Z_]+ ===|$)`,
         `=== ${tag} ===\\s*([\\s\\S]*?)(?==== [A-Z_]+ ===|$)`,
@@ -216,16 +284,34 @@ ${chapterContent}`;
       return match?.[1]?.trim() ?? "";
       return match?.[1]?.trim() ?? "";
     };
     };
 
 
-    const revisedContent = extract("REVISED_CONTENT");
     const fixedRaw = extract("FIXED_ISSUES");
     const fixedRaw = extract("FIXED_ISSUES");
+    const fixedIssues = fixedRaw
+      .split("\n")
+      .map((l) => l.trim())
+      .filter((l) => l.length > 0);
+
+    if (mode === "spot-fix") {
+      const patches = parseSpotFixPatches(extract("PATCHES"));
+      const patchResult = applySpotFixPatches(originalChapter, patches);
+
+      return {
+        revisedContent: patchResult.revisedContent,
+        wordCount: patchResult.revisedContent.length,
+        fixedIssues: patchResult.applied ? fixedIssues : [],
+        updatedState: extract("UPDATED_STATE") || "(状态卡未更新)",
+        updatedLedger: gp.numericalSystem
+          ? (extract("UPDATED_LEDGER") || "(账本未更新)")
+          : "",
+        updatedHooks: extract("UPDATED_HOOKS") || "(伏笔池未更新)",
+      };
+    }
+
+    const revisedContent = extract("REVISED_CONTENT");
 
 
     return {
     return {
       revisedContent,
       revisedContent,
       wordCount: revisedContent.length,
       wordCount: revisedContent.length,
-      fixedIssues: fixedRaw
-        .split("\n")
-        .map((l) => l.trim())
-        .filter((l) => l.length > 0),
+      fixedIssues,
       updatedState: extract("UPDATED_STATE") || "(状态卡未更新)",
       updatedState: extract("UPDATED_STATE") || "(状态卡未更新)",
       updatedLedger: gp.numericalSystem
       updatedLedger: gp.numericalSystem
         ? (extract("UPDATED_LEDGER") || "(账本未更新)")
         ? (extract("UPDATED_LEDGER") || "(账本未更新)")

+ 13 - 0
packages/core/src/agents/rules-reader.ts

@@ -3,6 +3,7 @@ import { join, dirname } from "node:path";
 import { fileURLToPath } from "node:url";
 import { fileURLToPath } from "node:url";
 import { parseGenreProfile, type ParsedGenreProfile } from "../models/genre-profile.js";
 import { parseGenreProfile, type ParsedGenreProfile } from "../models/genre-profile.js";
 import { parseBookRules, type ParsedBookRules } from "../models/book-rules.js";
 import { parseBookRules, type ParsedBookRules } from "../models/book-rules.js";
+import { BookConfigSchema } from "../models/book.js";
 
 
 const BUILTIN_GENRES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../genres");
 const BUILTIN_GENRES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../genres");
 
 
@@ -93,3 +94,15 @@ export async function readBookRules(bookDir: string): Promise<ParsedBookRules |
   if (!raw) return null;
   if (!raw) return null;
   return parseBookRules(raw);
   return parseBookRules(raw);
 }
 }
+
+export async function readBookLanguage(bookDir: string): Promise<"zh" | "en" | undefined> {
+  const raw = await tryReadFile(join(bookDir, "book.json"));
+  if (!raw) return undefined;
+
+  try {
+    const parsed = BookConfigSchema.pick({ language: true }).safeParse(JSON.parse(raw));
+    return parsed.success ? parsed.data.language : undefined;
+  } catch {
+    return undefined;
+  }
+}

+ 52 - 9
packages/core/src/agents/writer-parser.ts

@@ -28,12 +28,12 @@ export function parseCreativeOutput(
   // Fallback: if === TAG === parsing fails (common with local/small models),
   // Fallback: if === TAG === parsing fails (common with local/small models),
   // try to extract usable content from the raw output
   // try to extract usable content from the raw output
   if (!chapterContent) {
   if (!chapterContent) {
-    chapterContent = fallbackExtractContent(content);
+    chapterContent = fallbackExtractContent(content, countingMode);
   }
   }
 
 
   let title = extract("CHAPTER_TITLE");
   let title = extract("CHAPTER_TITLE");
   if (!title) {
   if (!title) {
-    title = fallbackExtractTitle(content, chapterNumber);
+    title = fallbackExtractTitle(content, chapterNumber, countingMode);
   }
   }
 
 
   return {
   return {
@@ -49,19 +49,33 @@ export function parseCreativeOutput(
  * Tries common patterns from local/small models, then falls back to
  * Tries common patterns from local/small models, then falls back to
  * stripping metadata and returning the longest prose block.
  * stripping metadata and returning the longest prose block.
  */
  */
-function fallbackExtractContent(raw: string): string {
+function fallbackExtractContent(raw: string, countingMode: LengthCountingMode): string {
   // Try markdown heading: # 第N章 ... followed by content
   // Try markdown heading: # 第N章 ... followed by content
   const headingMatch = raw.match(/^#\s*第\d+章[^\n]*\n+([\s\S]+)/m);
   const headingMatch = raw.match(/^#\s*第\d+章[^\n]*\n+([\s\S]+)/m);
   if (headingMatch) {
   if (headingMatch) {
     return headingMatch[1]!.trim();
     return headingMatch[1]!.trim();
   }
   }
 
 
+  if (countingMode === "en_words") {
+    const englishHeadingMatch = raw.match(/^#\s*Chapter\s+\d+(?::|\s+)([^\n]*)\n+([\s\S]+)/im);
+    if (englishHeadingMatch) {
+      return englishHeadingMatch[2]!.trim();
+    }
+  }
+
   // Try "正文" or "内容" labeled section
   // Try "正文" or "内容" labeled section
   const labelMatch = raw.match(/(?:正文|内容|章节内容)[::]\s*\n+([\s\S]+)/);
   const labelMatch = raw.match(/(?:正文|内容|章节内容)[::]\s*\n+([\s\S]+)/);
   if (labelMatch) {
   if (labelMatch) {
     return labelMatch[1]!.trim();
     return labelMatch[1]!.trim();
   }
   }
 
 
+  if (countingMode === "en_words") {
+    const englishLabelMatch = raw.match(/(?:content|chapter content)[::]\s*\n+([\s\S]+)/i);
+    if (englishLabelMatch) {
+      return englishLabelMatch[1]!.trim();
+    }
+  }
+
   // Last resort: strip lines that look like metadata/tags, keep the rest
   // Last resort: strip lines that look like metadata/tags, keep the rest
   const lines = raw.split("\n");
   const lines = raw.split("\n");
   const proseLines = lines.filter((line) => {
   const proseLines = lines.filter((line) => {
@@ -79,18 +93,28 @@ function fallbackExtractContent(raw: string): string {
 /**
 /**
  * Fallback title extraction when === CHAPTER_TITLE === tag is missing.
  * Fallback title extraction when === CHAPTER_TITLE === tag is missing.
  */
  */
-function fallbackExtractTitle(raw: string, chapterNumber: number): string {
+function fallbackExtractTitle(
+  raw: string,
+  chapterNumber: number,
+  countingMode: LengthCountingMode,
+): string {
   // Try: # 第N章 Title
   // Try: # 第N章 Title
   const headingMatch = raw.match(/^#\s*第\d+章\s*(.+)/m);
   const headingMatch = raw.match(/^#\s*第\d+章\s*(.+)/m);
   if (headingMatch) {
   if (headingMatch) {
     return headingMatch[1]!.trim();
     return headingMatch[1]!.trim();
   }
   }
+  if (countingMode === "en_words") {
+    const englishHeadingMatch = raw.match(/^#\s*Chapter\s+\d+(?::|\s+)\s*(.+)/im);
+    if (englishHeadingMatch) {
+      return englishHeadingMatch[1]!.trim();
+    }
+  }
   // Try: 章节标题:Title or CHAPTER_TITLE: Title (without === delimiters)
   // Try: 章节标题:Title or CHAPTER_TITLE: Title (without === delimiters)
   const labelMatch = raw.match(/(?:章节标题|CHAPTER_TITLE)[::]\s*(.+)/);
   const labelMatch = raw.match(/(?:章节标题|CHAPTER_TITLE)[::]\s*(.+)/);
   if (labelMatch) {
   if (labelMatch) {
     return labelMatch[1]!.trim();
     return labelMatch[1]!.trim();
   }
   }
-  return `第${chapterNumber}章`;
+  return defaultChapterTitle(chapterNumber, countingMode);
 }
 }
 
 
 export type ParsedWriterOutput = Omit<WriteChapterOutput, "postWriteErrors" | "postWriteWarnings">;
 export type ParsedWriterOutput = Omit<WriteChapterOutput, "postWriteErrors" | "postWriteWarnings">;
@@ -117,19 +141,38 @@ export function parseWriterOutput(
 
 
   return {
   return {
     chapterNumber,
     chapterNumber,
-    title: extract("CHAPTER_TITLE") || `第${chapterNumber}章`,
+    title: extract("CHAPTER_TITLE") || defaultChapterTitle(chapterNumber, countingMode),
     content: chapterContent,
     content: chapterContent,
     wordCount: countChapterLength(chapterContent, countingMode),
     wordCount: countChapterLength(chapterContent, countingMode),
     preWriteCheck: extract("PRE_WRITE_CHECK"),
     preWriteCheck: extract("PRE_WRITE_CHECK"),
     postSettlement: extract("POST_SETTLEMENT"),
     postSettlement: extract("POST_SETTLEMENT"),
-    updatedState: extract("UPDATED_STATE") || "(状态卡未更新)",
+    updatedState: extract("UPDATED_STATE") || defaultStatePlaceholder(countingMode),
     updatedLedger: genreProfile.numericalSystem
     updatedLedger: genreProfile.numericalSystem
-      ? (extract("UPDATED_LEDGER") || "(账本未更新)")
+      ? (extract("UPDATED_LEDGER") || defaultLedgerPlaceholder(countingMode))
       : "",
       : "",
-    updatedHooks: extract("UPDATED_HOOKS") || "(伏笔池未更新)",
+    updatedHooks: extract("UPDATED_HOOKS") || defaultHooksPlaceholder(countingMode),
     chapterSummary: extract("CHAPTER_SUMMARY"),
     chapterSummary: extract("CHAPTER_SUMMARY"),
     updatedSubplots: extract("UPDATED_SUBPLOTS"),
     updatedSubplots: extract("UPDATED_SUBPLOTS"),
     updatedEmotionalArcs: extract("UPDATED_EMOTIONAL_ARCS"),
     updatedEmotionalArcs: extract("UPDATED_EMOTIONAL_ARCS"),
     updatedCharacterMatrix: extract("UPDATED_CHARACTER_MATRIX"),
     updatedCharacterMatrix: extract("UPDATED_CHARACTER_MATRIX"),
   };
   };
 }
 }
+
+function defaultChapterTitle(
+  chapterNumber: number,
+  countingMode: LengthCountingMode,
+): string {
+  return countingMode === "en_words" ? `Chapter ${chapterNumber}` : `第${chapterNumber}章`;
+}
+
+function defaultStatePlaceholder(countingMode: LengthCountingMode): string {
+  return countingMode === "en_words" ? "(state card not updated)" : "(状态卡未更新)";
+}
+
+function defaultLedgerPlaceholder(countingMode: LengthCountingMode): string {
+  return countingMode === "en_words" ? "(ledger not updated)" : "(账本未更新)";
+}
+
+function defaultHooksPlaceholder(countingMode: LengthCountingMode): string {
+  return countingMode === "en_words" ? "(hooks pool not updated)" : "(伏笔池未更新)";
+}

+ 1 - 1
packages/core/src/index.ts

@@ -44,7 +44,7 @@ export { ArchitectAgent, type ArchitectOutput } from "./agents/architect.js";
 export { WriterAgent, type WriteChapterInput, type WriteChapterOutput, type TokenUsage } from "./agents/writer.js";
 export { WriterAgent, type WriteChapterInput, type WriteChapterOutput, type TokenUsage } from "./agents/writer.js";
 export { LengthNormalizerAgent, type NormalizeLengthInput, type NormalizeLengthOutput } from "./agents/length-normalizer.js";
 export { LengthNormalizerAgent, type NormalizeLengthInput, type NormalizeLengthOutput } from "./agents/length-normalizer.js";
 export { ContinuityAuditor, type AuditResult, type AuditIssue } from "./agents/continuity.js";
 export { ContinuityAuditor, type AuditResult, type AuditIssue } from "./agents/continuity.js";
-export { ReviserAgent, type ReviseOutput, type ReviseMode } from "./agents/reviser.js";
+export { ReviserAgent, DEFAULT_REVISE_MODE, type ReviseOutput, type ReviseMode } from "./agents/reviser.js";
 export { RadarAgent, type RadarResult, type RadarRecommendation } from "./agents/radar.js";
 export { RadarAgent, type RadarResult, type RadarRecommendation } from "./agents/radar.js";
 export { FanqieRadarSource, QidianRadarSource, TextRadarSource, type RadarSource, type PlatformRankings, type RankingEntry } from "./agents/radar-source.js";
 export { FanqieRadarSource, QidianRadarSource, TextRadarSource, type RadarSource, type PlatformRankings, type RankingEntry } from "./agents/radar-source.js";
 export { readGenreProfile, readBookRules, listAvailableGenres, getBuiltinGenresDir } from "./agents/rules-reader.js";
 export { readGenreProfile, readBookRules, listAvailableGenres, getBuiltinGenresDir } from "./agents/rules-reader.js";

+ 1 - 1
packages/core/src/models/project.ts

@@ -82,7 +82,7 @@ export const ProjectConfigSchema = z.object({
   notify: z.array(NotifyChannelSchema).default([]),
   notify: z.array(NotifyChannelSchema).default([]),
   detection: DetectionConfigSchema.optional(),
   detection: DetectionConfigSchema.optional(),
   modelOverrides: z.record(z.string(), ModelOverrideValueSchema).optional(),
   modelOverrides: z.record(z.string(), ModelOverrideValueSchema).optional(),
-  inputGovernanceMode: InputGovernanceModeSchema.default("legacy"),
+  inputGovernanceMode: InputGovernanceModeSchema.default("v2"),
   daemon: z.object({
   daemon: z.object({
     schedule: z.object({
     schedule: z.object({
       radarCron: z.string().default("0 */6 * * *"),
       radarCron: z.string().default("0 */6 * * *"),

+ 4 - 4
packages/core/src/pipeline/agent.ts

@@ -1,7 +1,7 @@
 import { chatWithTools, type AgentMessage, type ToolDefinition } from "../llm/provider.js";
 import { chatWithTools, type AgentMessage, type ToolDefinition } from "../llm/provider.js";
 import { PipelineRunner, type PipelineConfig } from "./runner.js";
 import { PipelineRunner, type PipelineConfig } from "./runner.js";
 import type { Platform, Genre } from "../models/book.js";
 import type { Platform, Genre } from "../models/book.js";
-import type { ReviseMode } from "../agents/reviser.js";
+import { DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js";
 
 
 /** Tool definitions for the agent loop. */
 /** Tool definitions for the agent loop. */
 const TOOLS: ReadonlyArray<ToolDefinition> = [
 const TOOLS: ReadonlyArray<ToolDefinition> = [
@@ -55,13 +55,13 @@ const TOOLS: ReadonlyArray<ToolDefinition> = [
   },
   },
   {
   {
     name: "revise_chapter",
     name: "revise_chapter",
-    description: "修订指定章节。根据审计问题修正。支持三种模式:polish(润色)、rewrite(改写)、rework(重写)。",
+    description: "修订指定章节。根据审计问题修正。默认使用 spot-fix 定点修复;也支持 polish(润色)、rewrite(改写)、rework(重写)、anti-detect。",
     parameters: {
     parameters: {
       type: "object",
       type: "object",
       properties: {
       properties: {
         bookId: { type: "string", description: "书籍ID" },
         bookId: { type: "string", description: "书籍ID" },
         chapterNumber: { type: "number", description: "章节号(不填则修订最新章)" },
         chapterNumber: { type: "number", description: "章节号(不填则修订最新章)" },
-        mode: { type: "string", enum: ["polish", "rewrite", "rework", "spot-fix", "anti-detect"], description: "修订模式(默认rewrite)" },
+        mode: { type: "string", enum: ["polish", "rewrite", "rework", "spot-fix", "anti-detect"], description: `修订模式(默认${DEFAULT_REVISE_MODE})` },
       },
       },
       required: ["bookId"],
       required: ["bookId"],
     },
     },
@@ -380,7 +380,7 @@ export async function executeAgentTool(
       const result = await pipeline.reviseDraft(
       const result = await pipeline.reviseDraft(
         args.bookId as string,
         args.bookId as string,
         args.chapterNumber as number | undefined,
         args.chapterNumber as number | undefined,
-        (args.mode as ReviseMode) ?? "rewrite",
+        (args.mode as ReviseMode) ?? DEFAULT_REVISE_MODE,
       );
       );
       return JSON.stringify(result);
       return JSON.stringify(result);
     }
     }

File diff suppressed because it is too large
+ 609 - 94
packages/core/src/pipeline/runner.ts


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

@@ -6,15 +6,22 @@ import type { ChapterMeta } from "../models/chapter.js";
 export class StateManager {
 export class StateManager {
   constructor(private readonly projectRoot: string) {}
   constructor(private readonly projectRoot: string) {}
 
 
-  private static readonly defaultAuthorIntent =
-    "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n";
+  private static defaultAuthorIntent(language: "zh" | "en"): string {
+    return language === "zh"
+      ? "# 作者意图\n\n(在这里描述这本书的长期创作方向。)\n"
+      : "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n";
+  }
 
 
-  private static readonly defaultCurrentFocus =
-    "# Current Focus\n\n## Active Focus\n\n(Describe what the next 1-3 chapters should prioritize.)\n";
+  private static defaultCurrentFocus(language: "zh" | "en"): string {
+    return language === "zh"
+      ? "# 当前聚焦\n\n## 当前重点\n\n(描述接下来 1-3 章最需要优先推进的内容。)\n"
+      : "# Current Focus\n\n## Active Focus\n\n(Describe what the next 1-3 chapters should prioritize.)\n";
+  }
 
 
   async ensureControlDocuments(bookId: string, authorIntent?: string): Promise<void> {
   async ensureControlDocuments(bookId: string, authorIntent?: string): Promise<void> {
     const storyDir = join(this.bookDir(bookId), "story");
     const storyDir = join(this.bookDir(bookId), "story");
     const runtimeDir = join(storyDir, "runtime");
     const runtimeDir = join(storyDir, "runtime");
+    const language = await this.resolveControlDocumentLanguage(bookId);
 
 
     await mkdir(storyDir, { recursive: true });
     await mkdir(storyDir, { recursive: true });
     await mkdir(runtimeDir, { recursive: true });
     await mkdir(runtimeDir, { recursive: true });
@@ -23,12 +30,12 @@ export class StateManager {
       join(storyDir, "author_intent.md"),
       join(storyDir, "author_intent.md"),
       authorIntent?.trim()
       authorIntent?.trim()
         ? authorIntent.trimEnd() + "\n"
         ? authorIntent.trimEnd() + "\n"
-        : StateManager.defaultAuthorIntent,
+        : StateManager.defaultAuthorIntent(language),
     );
     );
 
 
     await this.writeIfMissing(
     await this.writeIfMissing(
       join(storyDir, "current_focus.md"),
       join(storyDir, "current_focus.md"),
-      StateManager.defaultCurrentFocus,
+      StateManager.defaultCurrentFocus(language),
     );
     );
   }
   }
 
 
@@ -49,6 +56,16 @@ export class StateManager {
     return { authorIntent, currentFocus, runtimeDir };
     return { authorIntent, currentFocus, runtimeDir };
   }
   }
 
 
+  private async resolveControlDocumentLanguage(bookId: string): Promise<"zh" | "en"> {
+    try {
+      const raw = await readFile(join(this.bookDir(bookId), "book.json"), "utf-8");
+      const parsed = JSON.parse(raw) as { language?: unknown };
+      return parsed.language === "zh" ? "zh" : "en";
+    } catch {
+      return "en";
+    }
+  }
+
   async acquireBookLock(bookId: string): Promise<() => Promise<void>> {
   async acquireBookLock(bookId: string): Promise<() => Promise<void>> {
     await mkdir(this.bookDir(bookId), { recursive: true });
     await mkdir(this.bookDir(bookId), { recursive: true });
     const lockPath = join(this.bookDir(bookId), ".write.lock");
     const lockPath = join(this.bookDir(bookId), ".write.lock");
@@ -66,6 +83,11 @@ export class StateManager {
       const code = (e as NodeJS.ErrnoException | undefined)?.code;
       const code = (e as NodeJS.ErrnoException | undefined)?.code;
       if (code === "EEXIST") {
       if (code === "EEXIST") {
         const lockData = await readFile(lockPath, "utf-8").catch(() => "pid:unknown ts:unknown");
         const lockData = await readFile(lockPath, "utf-8").catch(() => "pid:unknown ts:unknown");
+        const lockPid = this.extractLockPid(lockData);
+        if (lockPid !== undefined && !this.isProcessAlive(lockPid)) {
+          await unlink(lockPath).catch(() => undefined);
+          return this.acquireBookLock(bookId);
+        }
         throw new Error(
         throw new Error(
           `Book "${bookId}" is locked by another process (${lockData}). ` +
           `Book "${bookId}" is locked by another process (${lockData}). ` +
             `If this is stale, delete ${lockPath}`,
             `If this is stale, delete ${lockPath}`,
@@ -82,6 +104,26 @@ export class StateManager {
     };
     };
   }
   }
 
 
+  private extractLockPid(lockData: string): number | undefined {
+    const match = lockData.match(/pid:(\d+)/);
+    if (!match) return undefined;
+    const pid = Number.parseInt(match[1] ?? "", 10);
+    return Number.isInteger(pid) && pid > 0 ? pid : undefined;
+  }
+
+  private isProcessAlive(pid: number): boolean {
+    try {
+      process.kill(pid, 0);
+      return true;
+    } catch (error) {
+      const code = (error as NodeJS.ErrnoException | undefined)?.code;
+      if (code === "ESRCH") {
+        return false;
+      }
+      return true;
+    }
+  }
+
   get booksDir(): string {
   get booksDir(): string {
     return join(this.projectRoot, "books");
     return join(this.projectRoot, "books");
   }
   }

+ 27 - 4
packages/core/src/utils/chapter-splitter.ts

@@ -8,7 +8,9 @@ export interface SplitChapter {
  *
  *
  * Default pattern matches:
  * Default pattern matches:
  * - "第一章 xxxx" / "第1章 xxxx"
  * - "第一章 xxxx" / "第1章 xxxx"
+ * - "第一回 xxxx" / "第1回 xxxx"
  * - "# 第1章 xxxx" / "## 第23章 xxxx"
  * - "# 第1章 xxxx" / "## 第23章 xxxx"
+ * - "CHAPTER I." / "CHAPTER II."
  *
  *
  * Each match marks the start of a new chapter. Content between matches
  * Each match marks the start of a new chapter. Content between matches
  * belongs to the preceding chapter.
  * belongs to the preceding chapter.
@@ -17,7 +19,7 @@ export function splitChapters(
   text: string,
   text: string,
   pattern?: string,
   pattern?: string,
 ): ReadonlyArray<SplitChapter> {
 ): ReadonlyArray<SplitChapter> {
-  const defaultPattern = /^#{0,2}\s*第[零一二三四五六七八九十百千万\d]+章\s*(.*)/;
+  const defaultPattern = /^#{0,2}\s*(?:第[零〇○O0一二三四五六七八九十百千万\d]+(?:章|回)(?:[::]|\s+)?\s*(.*)|Chapter\s+(?:\d+|[IVXLCDM]+)(?:\.|:|\s+)?\s*(.*))/i;
   const regex = pattern ? new RegExp(pattern, "m") : defaultPattern;
   const regex = pattern ? new RegExp(pattern, "m") : defaultPattern;
 
 
   const lines = text.split("\n");
   const lines = text.split("\n");
@@ -27,7 +29,7 @@ export function splitChapters(
     const match = lines[i]!.match(regex);
     const match = lines[i]!.match(regex);
     if (match) {
     if (match) {
       chapters.push({
       chapters.push({
-        title: (match[1] ?? "").trim(),
+        title: (match[1] ?? match[2] ?? "").trim(),
         startLine: i,
         startLine: i,
       });
       });
     }
     }
@@ -45,13 +47,34 @@ export function splitChapters(
 
 
     // Content starts after the title line
     // Content starts after the title line
     const contentLines = lines.slice(chapter.startLine + 1, nextStart);
     const contentLines = lines.slice(chapter.startLine + 1, nextStart);
-    const content = contentLines.join("\n").trim();
+    const content = stripTrailingLicense(contentLines.join("\n")).trim();
 
 
     result.push({
     result.push({
-      title: chapter.title || `第${i + 1}章`,
+      title: chapter.title || inferFallbackTitle(lines[chapter.startLine] ?? "", i + 1),
       content,
       content,
     });
     });
   }
   }
 
 
   return result;
   return result;
 }
 }
+
+function stripTrailingLicense(content: string): string {
+  const trailerMatch = content.match(/^\s*Project Gutenberg(?:™|\(TM\))?.*$/im);
+  if (!trailerMatch || trailerMatch.index === undefined) {
+    return content;
+  }
+
+  return content.slice(0, trailerMatch.index).trimEnd();
+}
+
+function inferFallbackTitle(headingLine: string, chapterNumber: number): string {
+  if (/chapter\s+(?:\d+|[ivxlcdm]+)/i.test(headingLine)) {
+    return `Chapter ${chapterNumber}`;
+  }
+
+  if (/第[零一二三四五六七八九十百千万\d]+回/.test(headingLine)) {
+    return `第${chapterNumber}回`;
+  }
+
+  return `第${chapterNumber}章`;
+}

+ 23 - 3
packages/core/src/utils/governed-context.ts

@@ -1,22 +1,42 @@
 import type { ContextPackage } from "../models/input-governance.js";
 import type { ContextPackage } from "../models/input-governance.js";
 
 
-export function buildGovernedMemoryEvidenceBlocks(contextPackage: ContextPackage): {
+export function buildGovernedMemoryEvidenceBlocks(
+  contextPackage: ContextPackage,
+  language?: "zh" | "en",
+): {
   readonly hooksBlock?: string;
   readonly hooksBlock?: string;
   readonly summariesBlock?: string;
   readonly summariesBlock?: string;
+  readonly volumeSummariesBlock?: string;
 } {
 } {
+  const resolvedLanguage = language ?? "zh";
   const hookEntries = contextPackage.selectedContext.filter((entry) =>
   const hookEntries = contextPackage.selectedContext.filter((entry) =>
     entry.source.startsWith("story/pending_hooks.md#"),
     entry.source.startsWith("story/pending_hooks.md#"),
   );
   );
   const summaryEntries = contextPackage.selectedContext.filter((entry) =>
   const summaryEntries = contextPackage.selectedContext.filter((entry) =>
     entry.source.startsWith("story/chapter_summaries.md#"),
     entry.source.startsWith("story/chapter_summaries.md#"),
   );
   );
+  const volumeSummaryEntries = contextPackage.selectedContext.filter((entry) =>
+    entry.source.startsWith("story/volume_summaries.md#"),
+  );
 
 
   return {
   return {
     hooksBlock: hookEntries.length > 0
     hooksBlock: hookEntries.length > 0
-      ? renderEvidenceBlock("已选伏笔证据", hookEntries)
+      ? renderEvidenceBlock(
+          resolvedLanguage === "en" ? "Selected Hook Evidence" : "已选伏笔证据",
+          hookEntries,
+        )
       : undefined,
       : undefined,
     summariesBlock: summaryEntries.length > 0
     summariesBlock: summaryEntries.length > 0
-      ? renderEvidenceBlock("已选章节摘要证据", summaryEntries)
+      ? renderEvidenceBlock(
+          resolvedLanguage === "en" ? "Selected Chapter Summary Evidence" : "已选章节摘要证据",
+          summaryEntries,
+        )
+      : undefined,
+    volumeSummariesBlock: volumeSummaryEntries.length > 0
+      ? renderEvidenceBlock(
+          resolvedLanguage === "en" ? "Selected Volume Summary Evidence" : "已选卷级摘要证据",
+          volumeSummaryEntries,
+        )
       : undefined,
       : undefined,
   };
   };
 }
 }

+ 215 - 18
packages/core/src/utils/memory-retrieval.ts

@@ -6,9 +6,16 @@ export interface MemorySelection {
   readonly summaries: ReadonlyArray<StoredSummary>;
   readonly summaries: ReadonlyArray<StoredSummary>;
   readonly hooks: ReadonlyArray<StoredHook>;
   readonly hooks: ReadonlyArray<StoredHook>;
   readonly facts: ReadonlyArray<Fact>;
   readonly facts: ReadonlyArray<Fact>;
+  readonly volumeSummaries: ReadonlyArray<VolumeSummarySelection>;
   readonly dbPath?: string;
   readonly dbPath?: string;
 }
 }
 
 
+export interface VolumeSummarySelection {
+  readonly heading: string;
+  readonly content: string;
+  readonly anchor: string;
+}
+
 export async function retrieveMemorySelection(params: {
 export async function retrieveMemorySelection(params: {
   readonly bookDir: string;
   readonly bookDir: string;
   readonly chapterNumber: number;
   readonly chapterNumber: number;
@@ -17,14 +24,10 @@ export async function retrieveMemorySelection(params: {
   readonly mustKeep?: ReadonlyArray<string>;
   readonly mustKeep?: ReadonlyArray<string>;
 }): Promise<MemorySelection> {
 }): Promise<MemorySelection> {
   const storyDir = join(params.bookDir, "story");
   const storyDir = join(params.bookDir, "story");
-  const [summariesMarkdown, hooksMarkdown, currentStateMarkdown] = await Promise.all([
-    readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
-    readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
+  const [currentStateMarkdown, volumeSummariesMarkdown] = await Promise.all([
     readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
     readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
+    readFile(join(storyDir, "volume_summaries.md"), "utf-8").catch(() => ""),
   ]);
   ]);
-
-  const summaries = parseChapterSummariesMarkdown(summariesMarkdown);
-  const hooks = parsePendingHooksMarkdown(hooksMarkdown);
   const facts = parseCurrentStateFacts(
   const facts = parseCurrentStateFacts(
     currentStateMarkdown,
     currentStateMarkdown,
     Math.max(0, params.chapterNumber - 1),
     Math.max(0, params.chapterNumber - 1),
@@ -34,13 +37,31 @@ export async function retrieveMemorySelection(params: {
     params.outlineNode,
     params.outlineNode,
     params.mustKeep ?? [],
     params.mustKeep ?? [],
   );
   );
+  const volumeSummaries = selectRelevantVolumeSummaries(
+    parseVolumeSummariesMarkdown(volumeSummariesMarkdown),
+    queryTerms,
+  );
 
 
   const memoryDb = openMemoryDB(params.bookDir);
   const memoryDb = openMemoryDB(params.bookDir);
   if (memoryDb) {
   if (memoryDb) {
     try {
     try {
-      memoryDb.replaceSummaries(summaries);
-      memoryDb.replaceHooks(hooks);
-      memoryDb.replaceCurrentFacts(facts);
+      if (memoryDb.getChapterCount() === 0) {
+        const summariesMarkdown = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
+        const summaries = parseChapterSummariesMarkdown(summariesMarkdown);
+        if (summaries.length > 0) {
+          memoryDb.replaceSummaries(summaries);
+        }
+      }
+      if (memoryDb.getActiveHooks().length === 0) {
+        const hooksMarkdown = await readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => "");
+        const hooks = parsePendingHooksMarkdown(hooksMarkdown);
+        if (hooks.length > 0) {
+          memoryDb.replaceHooks(hooks);
+        }
+      }
+      if (memoryDb.getCurrentFacts().length === 0 && facts.length > 0) {
+        memoryDb.replaceCurrentFacts(facts);
+      }
 
 
       return {
       return {
         summaries: selectRelevantSummaries(
         summaries: selectRelevantSummaries(
@@ -50,6 +71,7 @@ export async function retrieveMemorySelection(params: {
         ),
         ),
         hooks: selectRelevantHooks(memoryDb.getActiveHooks(), queryTerms),
         hooks: selectRelevantHooks(memoryDb.getActiveHooks(), queryTerms),
         facts: selectRelevantFacts(memoryDb.getCurrentFacts(), queryTerms),
         facts: selectRelevantFacts(memoryDb.getCurrentFacts(), queryTerms),
+        volumeSummaries,
         dbPath: join(storyDir, "memory.db"),
         dbPath: join(storyDir, "memory.db"),
       };
       };
     } finally {
     } finally {
@@ -57,19 +79,55 @@ export async function retrieveMemorySelection(params: {
     }
     }
   }
   }
 
 
+  const [summariesMarkdown, hooksMarkdown] = await Promise.all([
+    readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
+    readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
+  ]);
+  const summaries = parseChapterSummariesMarkdown(summariesMarkdown);
+  const hooks = parsePendingHooksMarkdown(hooksMarkdown);
+
   return {
   return {
     summaries: selectRelevantSummaries(summaries, params.chapterNumber, queryTerms),
     summaries: selectRelevantSummaries(summaries, params.chapterNumber, queryTerms),
     hooks: selectRelevantHooks(hooks, queryTerms),
     hooks: selectRelevantHooks(hooks, queryTerms),
     facts: selectRelevantFacts(facts, queryTerms),
     facts: selectRelevantFacts(facts, queryTerms),
+    volumeSummaries,
   };
   };
 }
 }
 
 
-export function renderSummarySnapshot(summaries: ReadonlyArray<StoredSummary>): string {
+export function extractQueryTerms(goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray<string>): string[] {
+  const primaryTerms = uniqueTerms([
+    ...extractTermsFromText(stripNegativeGuidance(goal)),
+    ...mustKeep.flatMap((item) => extractTermsFromText(item)),
+  ]);
+
+  if (primaryTerms.length >= 2) {
+    return primaryTerms.slice(0, 12);
+  }
+
+  return uniqueTerms([
+    ...primaryTerms,
+    ...extractTermsFromText(stripNegativeGuidance(outlineNode ?? "")),
+  ]).slice(0, 12);
+}
+
+export function renderSummarySnapshot(
+  summaries: ReadonlyArray<StoredSummary>,
+  language: "zh" | "en" = "zh",
+): string {
   if (summaries.length === 0) return "- none";
   if (summaries.length === 0) return "- none";
 
 
+  const headers = language === "en"
+    ? [
+      "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+    ]
+    : [
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+    ];
+
   return [
   return [
-    "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
-    "| --- | --- | --- | --- | --- | --- | --- | --- |",
+    ...headers,
     ...summaries.map((summary) => [
     ...summaries.map((summary) => [
       summary.chapter,
       summary.chapter,
       summary.title,
       summary.title,
@@ -83,12 +141,24 @@ export function renderSummarySnapshot(summaries: ReadonlyArray<StoredSummary>):
   ].join("\n");
   ].join("\n");
 }
 }
 
 
-export function renderHookSnapshot(hooks: ReadonlyArray<StoredHook>): string {
+export function renderHookSnapshot(
+  hooks: ReadonlyArray<StoredHook>,
+  language: "zh" | "en" = "zh",
+): string {
   if (hooks.length === 0) return "- none";
   if (hooks.length === 0) return "- none";
 
 
+  const headers = language === "en"
+    ? [
+      "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- |",
+    ]
+    : [
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- |",
+    ];
+
   return [
   return [
-    "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
-    "| --- | --- | --- | --- | --- | --- | --- |",
+    ...headers,
     ...hooks.map((hook) => [
     ...hooks.map((hook) => [
       hook.hookId,
       hook.hookId,
       hook.startChapter,
       hook.startChapter,
@@ -109,7 +179,7 @@ function openMemoryDB(bookDir: string): MemoryDB | null {
   }
   }
 }
 }
 
 
-function extractQueryTerms(goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray<string>): string[] {
+function buildLegacyQueryTerms(goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray<string>): string[] {
   const stopWords = new Set([
   const stopWords = new Set([
     "bring", "focus", "back", "chapter", "clear", "narrative", "before", "opening",
     "bring", "focus", "back", "chapter", "clear", "narrative", "before", "opening",
     "track", "the", "with", "from", "that", "this", "into", "still", "cannot",
     "track", "the", "with", "from", "that", "this", "into", "still", "cannot",
@@ -128,7 +198,74 @@ function extractQueryTerms(goal: string, outlineNode: string | undefined, mustKe
   )].slice(0, 12);
   )].slice(0, 12);
 }
 }
 
 
-function parseChapterSummariesMarkdown(markdown: string): StoredSummary[] {
+function extractTermsFromText(text: string): string[] {
+  if (!text.trim()) return [];
+
+  const stopWords = new Set([
+    "bring", "focus", "back", "chapter", "clear", "narrative", "before", "opening",
+    "track", "the", "with", "from", "that", "this", "into", "still", "cannot",
+    "current", "state", "advance", "conflict", "story", "keep", "must", "local",
+    "does", "not", "only", "just", "then", "than",
+  ]);
+
+  const normalized = text.replace(/第\d+章/g, " ");
+  const english = (normalized.match(/[a-z]{4,}/gi) ?? [])
+    .map((term) => term.trim())
+    .filter((term) => term.length >= 2)
+    .filter((term) => !stopWords.has(term.toLowerCase()));
+
+  const chineseSegments = normalized.match(/[\u4e00-\u9fff]{2,}/g) ?? [];
+  const chinese = chineseSegments.flatMap((segment) => extractChineseFocusTerms(segment));
+
+  return [...english, ...chinese];
+}
+
+function extractChineseFocusTerms(segment: string): string[] {
+  const stripped = segment
+    .replace(/^(本章|继续|重新|拉回|回到|推进|优先|围绕|聚焦|坚持|保持|把注意力|注意力|将注意力|请把注意力|先把注意力)+/, "")
+    .replace(/^(处理|推进|回拉|拉回到)+/, "")
+    .trim();
+
+  const target = stripped.length >= 2 ? stripped : segment;
+  const terms = new Set<string>();
+
+  if (target.length <= 4) {
+    terms.add(target);
+  }
+
+  for (let size = 2; size <= 4; size += 1) {
+    if (target.length >= size) {
+      terms.add(target.slice(-size));
+    }
+  }
+
+  return [...terms].filter((term) => term.length >= 2);
+}
+
+function stripNegativeGuidance(text: string): string {
+  if (!text) return "";
+
+  return text
+    .replace(/\b(do not|don't|avoid|without|instead of)\b[\s\S]*$/i, " ")
+    .replace(/(?:不要|不让|别|禁止|避免|但不允许)[\s\S]*$/u, " ")
+    .trim();
+}
+
+function uniqueTerms(terms: ReadonlyArray<string>): string[] {
+  const result: string[] = [];
+  const seen = new Set<string>();
+
+  for (const term of terms) {
+    const normalized = term.trim().toLowerCase();
+    if (!normalized || seen.has(normalized)) continue;
+    seen.add(normalized);
+    result.push(term.trim());
+  }
+
+  return result;
+}
+
+export function parseChapterSummariesMarkdown(markdown: string): StoredSummary[] {
   const rows = parseMarkdownTableRows(markdown)
   const rows = parseMarkdownTableRows(markdown)
     .filter((row) => /^\d+$/.test(row[0] ?? ""));
     .filter((row) => /^\d+$/.test(row[0] ?? ""));
 
 
@@ -144,7 +281,7 @@ function parseChapterSummariesMarkdown(markdown: string): StoredSummary[] {
   }));
   }));
 }
 }
 
 
-function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
+export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
   const tableRows = parseMarkdownTableRows(markdown)
   const tableRows = parseMarkdownTableRows(markdown)
     .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id");
     .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id");
 
 
@@ -237,6 +374,27 @@ function parseMarkdownTableRows(markdown: string): string[][] {
     .filter((cells) => cells.some(Boolean));
     .filter((cells) => cells.some(Boolean));
 }
 }
 
 
+function parseVolumeSummariesMarkdown(markdown: string): VolumeSummarySelection[] {
+  if (!markdown.trim()) return [];
+
+  const sections = markdown
+    .split(/^##\s+/m)
+    .map((section) => section.trim())
+    .filter(Boolean);
+
+  return sections.map((section) => {
+    const [headingLine, ...bodyLines] = section.split("\n");
+    const heading = headingLine?.trim() ?? "";
+    const content = bodyLines.join("\n").trim();
+
+    return {
+      heading,
+      content,
+      anchor: slugifyAnchor(heading),
+    };
+  }).filter((section) => section.heading.length > 0 && section.content.length > 0);
+}
+
 function isStateTableHeaderRow(row: ReadonlyArray<string>): boolean {
 function isStateTableHeaderRow(row: ReadonlyArray<string>): boolean {
   const first = (row[0] ?? "").trim().toLowerCase();
   const first = (row[0] ?? "").trim().toLowerCase();
   const second = (row[1] ?? "").trim().toLowerCase();
   const second = (row[1] ?? "").trim().toLowerCase();
@@ -337,6 +495,36 @@ function selectRelevantFacts(
     .map((entry) => entry.fact);
     .map((entry) => entry.fact);
 }
 }
 
 
+function selectRelevantVolumeSummaries(
+  summaries: ReadonlyArray<VolumeSummarySelection>,
+  queryTerms: ReadonlyArray<string>,
+): VolumeSummarySelection[] {
+  if (summaries.length === 0) return [];
+
+  const ranked = summaries
+    .map((summary, index) => {
+      const text = `${summary.heading} ${summary.content}`;
+      const termScore = queryTerms.reduce(
+        (score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0),
+        0,
+      );
+
+      return {
+        index,
+        summary,
+        score: termScore + index,
+        matched: matchesAny(text, queryTerms),
+      };
+    })
+    .filter((entry, index, all) => entry.matched || index === all.length - 1)
+    .sort((left, right) => right.score - left.score)
+    .slice(0, 2)
+    .sort((left, right) => left.index - right.index)
+    .map((entry) => entry.summary);
+
+  return ranked;
+}
+
 function scoreSummary(summary: StoredSummary, chapterNumber: number, queryTerms: ReadonlyArray<string>): number {
 function scoreSummary(summary: StoredSummary, chapterNumber: number, queryTerms: ReadonlyArray<string>): number {
   const text = [
   const text = [
     summary.title,
     summary.title,
@@ -376,3 +564,12 @@ function parseInteger(value: string | undefined): number {
 function escapeTableCell(value: string | number): string {
 function escapeTableCell(value: string | number): string {
   return String(value).replace(/\|/g, "\\|").trim();
   return String(value).replace(/\|/g, "\\|").trim();
 }
 }
+
+function slugifyAnchor(value: string): string {
+  return value
+    .trim()
+    .toLowerCase()
+    .replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
+    .replace(/^-+|-+$/g, "")
+    || "volume-summary";
+}

Some files were not shown because too many files changed in this diff