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

fix: govern chapter analyzer inputs across v2 flows

Ma 7 месяцев назад
Родитель
Сommit
f82563ef2a

+ 175 - 0
packages/core/src/__tests__/chapter-analyzer.test.ts

@@ -396,4 +396,179 @@ describe("ChapterAnalyzerAgent", () => {
       await rm(bookDir, { recursive: true, force: true });
     }
   });
+
+  it("uses governed control inputs instead of old broad truth-file blocks when provided", async () => {
+    const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-governed-"));
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Full bible should stay out of governed analyzer prompts.\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nReturn to the mentor oath conflict.\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, "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, "subplot_board.md"), [
+        "# Subplot Board",
+        "",
+        "| subplot | status | last_update | notes |",
+        "| --- | --- | --- | --- |",
+        "| Guild trail | open | 99 | Still active |",
+        "| Harbor tax | resolved | 40 | Closed long ago |",
+        "",
+      ].join("\n"), "utf-8"),
+      writeFile(join(storyDir, "emotional_arcs.md"), [
+        "# Emotional Arcs",
+        "",
+        "| chapter | character | emotion | trigger | direction |",
+        "| --- | --- | --- | --- | --- |",
+        "| 95 | Lin Yue | grief | mentor silence | down |",
+        "| 100 | Lin Yue | resolve | oath token | up |",
+        "",
+      ].join("\n"), "utf-8"),
+      writeFile(join(storyDir, "character_matrix.md"), [
+        "# Character Matrix",
+        "",
+        "### Character Profiles",
+        "| character | role | status | notes |",
+        "| --- | --- | --- | --- |",
+        "| Lin Yue | protagonist | active | carries oath token |",
+        "| Mentor Shen | mentor | missing | tied to oath debt |",
+        "| Harbor Clerk | clerk | inactive | old tax subplot |",
+        "",
+      ].join("\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.",
+        chapterIntent: "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor oath conflict.\n",
+        contextPackage: {
+          chapter: 100,
+          selectedContext: [
+            {
+              source: "story/pending_hooks.md#mentor-oath",
+              reason: "Primary hook for this chapter",
+              excerpt: "mentor-oath remains unresolved",
+            },
+            {
+              source: "story/chapter_summaries.md#99",
+              reason: "Closest relevant summary",
+              excerpt: "Mentor oath debt sharpened",
+            },
+          ],
+        },
+        ruleStack: {
+          layers: [
+            { id: "L1", name: "Global", precedence: 1, scope: "global" },
+            { id: "L2", name: "Book", precedence: 2, scope: "book" },
+          ],
+          sections: {
+            hard: ["story_bible"],
+            soft: ["author_intent"],
+            diagnostic: ["anti_ai_checks"],
+          },
+          overrideEdges: [],
+          activeOverrides: [
+            {
+              from: "brief",
+              to: "current_focus",
+              reason: "Keep the chapter on the oath debt",
+              target: "focus",
+            },
+          ],
+        },
+      });
+
+      const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
+      const userPrompt = messages[1]?.content ?? "";
+
+      expect(userPrompt).toContain("## Chapter Control Inputs (compiled by Planner/Composer)");
+      expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath");
+      expect(userPrompt).toContain("Selected Hook Evidence");
+      expect(userPrompt).not.toContain("## Story Bible");
+      expect(userPrompt).not.toContain("Full bible should stay out of governed analyzer prompts");
+      expect(userPrompt).not.toContain("guild-route");
+    } finally {
+      await rm(bookDir, { recursive: true, force: true });
+    }
+  });
 });

+ 122 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -1317,6 +1317,71 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("passes governed control inputs into final truth rebuild 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: "Original draft body.",
+        wordCount: "Original draft body.".length,
+      }),
+    );
+    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: "Governed revised body.",
+        wordCount: "Governed revised body.".length,
+      }),
+    );
+    const analyzeChapter = vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
+      createAnalyzedOutput({
+        content: "Governed revised body.",
+        wordCount: "Governed revised body.".length,
+      }),
+    );
+
+    try {
+      await runner.writeNextChapter(bookId, 220);
+
+      expect(analyzeChapter.mock.calls[0]?.[0]).toMatchObject({
+        chapterIntent: expect.stringContaining("# Chapter Intent"),
+        contextPackage: expect.objectContaining({
+          selectedContext: expect.any(Array),
+        }),
+        ruleStack: expect.objectContaining({
+          activeOverrides: expect.any(Array),
+        }),
+      });
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("normalizes revised output once before re-audit when it leaves the target band", async () => {
     const { root, runner, bookId } = await createRunnerFixture();
     const writerDraft = "中段正文。".repeat(40);
@@ -2511,6 +2576,63 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("passes governed control inputs into import replay analyzer in v2 mode", async () => {
+    const { root, runner, bookId } = await createRunnerFixture();
+
+    vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
+      storyBible: "# Story Bible\n\n- Keep the harbor search grounded in the missing captain thread.\n",
+      volumeOutline: "# Volume Outline\n\n## Volume 1\n- Chapter 1: Mara arrives at the harbor with the sealed letter.\n",
+      bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n\n- Stay close to Mara's viewpoint.\n",
+      currentState: createStateCard({
+        chapter: 0,
+        location: "Harbor gate",
+        protagonistState: "Mara arrives carrying a sealed letter.",
+        goal: "Enter the harbor unnoticed.",
+        conflict: "The harbor watch is hunting the captain's courier.",
+      }),
+      pendingHooks: [
+        "# Pending Hooks",
+        "",
+        "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
+        "| --- | --- | --- | --- | --- | --- | --- |",
+        "| captain-letter | 1 | mystery | open | 0 | The captain's disappearance is explained. | The sealed letter points to the missing captain. |",
+        "",
+      ].join("\n"),
+    });
+
+    const analyzeChapter = 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"),
+      }),
+    );
+    vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
+    vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
+
+    try {
+      await runner.importChapters({
+        bookId,
+        chapters: [
+          { title: "Prelude", content: "A cold wind crossed the harbor." },
+        ],
+      });
+
+      expect(analyzeChapter.mock.calls[0]?.[0]).toMatchObject({
+        chapterIntent: expect.stringContaining("# Chapter Intent"),
+        contextPackage: expect.objectContaining({
+          selectedContext: expect.any(Array),
+        }),
+        ruleStack: expect.objectContaining({
+          activeOverrides: expect.any(Array),
+        }),
+      });
+    } 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");

+ 143 - 56
packages/core/src/agents/chapter-analyzer.ts

@@ -1,8 +1,15 @@
 import { BaseAgent } from "./base.js";
 import type { BookConfig } from "../models/book.js";
 import type { GenreProfile } from "../models/genre-profile.js";
+import type { ContextPackage, RuleStack } from "../models/input-governance.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { parseWriterOutput, type ParsedWriterOutput } from "./writer-parser.js";
+import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
+import {
+  buildGovernedCharacterMatrixWorkingSet,
+  buildGovernedHookWorkingSet,
+} from "../utils/governed-working-set.js";
+import { filterEmotionalArcs, filterSubplots } from "../utils/context-filter.js";
 import { countChapterLength, resolveLengthCountingMode } from "../utils/length-metrics.js";
 import { retrieveMemorySelection } from "../utils/memory-retrieval.js";
 import { readFile } from "node:fs/promises";
@@ -14,6 +21,9 @@ export interface AnalyzeChapterInput {
   readonly chapterNumber: number;
   readonly chapterContent: string;
   readonly chapterTitle?: string;
+  readonly chapterIntent?: string;
+  readonly contextPackage?: ContextPackage;
+  readonly ruleStack?: RuleStack;
 }
 
 export type AnalyzeChapterOutput = ParsedWriterOutput;
@@ -46,6 +56,8 @@ export class ChapterAnalyzerAgent extends BaseAgent {
     ]);
     const parsedBookRules = await readBookRules(bookDir);
     const bookRulesBody = parsedBookRules?.body ?? "";
+    const bookRules = parsedBookRules?.rules;
+    const governedMode = Boolean(input.chapterIntent && input.contextPackage && input.ruleStack);
     const memorySelection = await retrieveMemorySelection({
       bookDir,
       chapterNumber,
@@ -56,6 +68,35 @@ export class ChapterAnalyzerAgent extends BaseAgent {
       memorySelection.summaries,
       resolvedLanguage,
     );
+    const governedMemoryBlocks = input.contextPackage
+      ? buildGovernedMemoryEvidenceBlocks(input.contextPackage, resolvedLanguage)
+      : undefined;
+    const hooksWorkingSet = governedMode && input.contextPackage
+      ? buildGovernedHookWorkingSet({
+          hooksMarkdown: hooks,
+          contextPackage: input.contextPackage,
+          chapterIntent: input.chapterIntent,
+          chapterNumber,
+          language: resolvedLanguage,
+        })
+      : hooks;
+    const subplotWorkingSet = governedMode
+      ? filterSubplots(subplotBoard)
+      : subplotBoard;
+    const emotionalWorkingSet = governedMode
+      ? filterEmotionalArcs(emotionalArcs, chapterNumber)
+      : emotionalArcs;
+    const matrixWorkingSet = governedMode && input.chapterIntent && input.contextPackage
+      ? buildGovernedCharacterMatrixWorkingSet({
+          matrixMarkdown: characterMatrix,
+          chapterIntent: input.chapterIntent,
+          contextPackage: input.contextPackage,
+          protagonistName: bookRules?.protagonist?.name,
+        })
+      : characterMatrix;
+    const reducedControlBlock = governedMode && input.chapterIntent && input.contextPackage && input.ruleStack
+      ? this.buildReducedControlBlock(input.chapterIntent, input.contextPackage, input.ruleStack, resolvedLanguage)
+      : "";
 
     const systemPrompt = this.buildSystemPrompt(
       book,
@@ -70,15 +111,57 @@ export class ChapterAnalyzerAgent extends BaseAgent {
       chapterNumber,
       chapterContent,
       chapterTitle,
-      storyBible,
-      volumeOutline,
       currentState,
       ledger: genreProfile.numericalSystem ? ledger : "",
-      hooks,
+      hooks: hooksWorkingSet,
       chapterSummaries,
-      subplotBoard,
-      emotionalArcs,
-      characterMatrix,
+      subplotBoard: subplotWorkingSet,
+      emotionalArcs: emotionalWorkingSet,
+      characterMatrix: matrixWorkingSet,
+      bibleBlock: !governedMode && storyBible !== this.missingFilePlaceholder(resolvedLanguage)
+        ? resolvedLanguage === "en"
+          ? `\n## Story Bible\n${storyBible}\n`
+          : `\n## 世界观设定\n${storyBible}\n`
+        : "",
+      outlineOrControlBlock: reducedControlBlock || (
+        volumeOutline !== this.missingFilePlaceholder(resolvedLanguage)
+          ? resolvedLanguage === "en"
+            ? `\n## Volume Outline\n${volumeOutline}\n`
+            : `\n## 卷纲\n${volumeOutline}\n`
+          : ""
+      ),
+      hooksBlock: governedMemoryBlocks?.hooksBlock
+        ?? (
+          hooksWorkingSet !== this.missingFilePlaceholder(resolvedLanguage)
+            ? resolvedLanguage === "en"
+              ? `\n## Current Hooks\n${hooksWorkingSet}\n`
+              : `\n## 当前伏笔池\n${hooksWorkingSet}\n`
+            : ""
+        ),
+      summariesBlock: governedMemoryBlocks?.summariesBlock
+        ?? (
+          chapterSummaries !== this.missingFilePlaceholder(resolvedLanguage)
+            ? resolvedLanguage === "en"
+              ? `\n## Existing Chapter Summaries\n${chapterSummaries}\n`
+              : `\n## 已有章节摘要\n${chapterSummaries}\n`
+            : ""
+        ),
+      volumeSummariesBlock: governedMemoryBlocks?.volumeSummariesBlock ?? "",
+      subplotBlock: subplotWorkingSet !== this.missingFilePlaceholder(resolvedLanguage)
+        ? resolvedLanguage === "en"
+          ? `\n## Current Subplot Board\n${subplotWorkingSet}\n`
+          : `\n## 当前支线进度板\n${subplotWorkingSet}\n`
+        : "",
+      emotionalBlock: emotionalWorkingSet !== this.missingFilePlaceholder(resolvedLanguage)
+        ? resolvedLanguage === "en"
+          ? `\n## Current Emotional Arcs\n${emotionalWorkingSet}\n`
+          : `\n## 当前情感弧线\n${emotionalWorkingSet}\n`
+        : "",
+      matrixBlock: matrixWorkingSet !== this.missingFilePlaceholder(resolvedLanguage)
+        ? resolvedLanguage === "en"
+          ? `\n## Current Character Matrix\n${matrixWorkingSet}\n`
+          : `\n## 当前角色交互矩阵\n${matrixWorkingSet}\n`
+        : "",
     });
 
     const response = await this.chat(
@@ -317,8 +400,6 @@ ${bookRulesBody ? `## 本书规则\n\n${bookRulesBody}` : ""}
     readonly chapterNumber: number;
     readonly chapterContent: string;
     readonly chapterTitle?: string;
-    readonly storyBible: string;
-    readonly volumeOutline: string;
     readonly currentState: string;
     readonly ledger: string;
     readonly hooks: string;
@@ -326,6 +407,14 @@ ${bookRulesBody ? `## 本书规则\n\n${bookRulesBody}` : ""}
     readonly subplotBoard: string;
     readonly emotionalArcs: string;
     readonly characterMatrix: string;
+    readonly hooksBlock: string;
+    readonly summariesBlock: string;
+    readonly volumeSummariesBlock: string;
+    readonly subplotBlock: string;
+    readonly emotionalBlock: string;
+    readonly matrixBlock: string;
+    readonly bibleBlock: string;
+    readonly outlineOrControlBlock: string;
   }): string {
     if (params.language === "en") {
       const titleLine = params.chapterTitle
@@ -336,22 +425,6 @@ ${bookRulesBody ? `## 本书规则\n\n${bookRulesBody}` : ""}
         ? `\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
@@ -361,14 +434,7 @@ ${params.chapterContent}
 ## Current State
 ${params.currentState}
 ${ledgerBlock}
-## Current Hooks
-${params.hooks}
-${summariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}
-## Story Bible
-${params.storyBible}
-
-## Volume Outline
-${params.volumeOutline}
+${params.hooksBlock}${params.volumeSummariesBlock}${params.subplotBlock}${params.emotionalBlock}${params.matrixBlock}${params.summariesBlock}${params.outlineOrControlBlock}${params.bibleBlock}
 
 Please return the result strictly in the === TAG === format.`;
     }
@@ -381,22 +447,6 @@ Please return the result strictly in the === TAG === format.`;
       ? `\n## 当前资源账本\n${params.ledger}\n`
       : "";
 
-    const summariesBlock = params.chapterSummaries !== "(文件尚未创建)"
-      ? `\n## 已有章节摘要\n${params.chapterSummaries}\n`
-      : "";
-
-    const subplotBlock = params.subplotBoard !== "(文件尚未创建)"
-      ? `\n## 当前支线进度板\n${params.subplotBoard}\n`
-      : "";
-
-    const emotionalBlock = params.emotionalArcs !== "(文件尚未创建)"
-      ? `\n## 当前情感弧线\n${params.emotionalArcs}\n`
-      : "";
-
-    const matrixBlock = params.characterMatrix !== "(文件尚未创建)"
-      ? `\n## 当前角色交互矩阵\n${params.characterMatrix}\n`
-      : "";
-
     return `请分析第${params.chapterNumber}章正文,更新所有追踪文件。
 ${titleLine}
 ## 正文内容
@@ -406,18 +456,55 @@ ${params.chapterContent}
 ## 当前状态卡
 ${params.currentState}
 ${ledgerBlock}
-## 当前伏笔池
-${params.hooks}
-${summariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}
-## 世界观设定
-${params.storyBible}
-
-## 卷纲
-${params.volumeOutline}
+${params.hooksBlock}${params.volumeSummariesBlock}${params.subplotBlock}${params.emotionalBlock}${params.matrixBlock}${params.summariesBlock}${params.outlineOrControlBlock}${params.bibleBlock}
 
 请严格按照 === TAG === 格式输出分析结果。`;
   }
 
+  private buildReducedControlBlock(
+    chapterIntent: string,
+    contextPackage: ContextPackage,
+    ruleStack: RuleStack,
+    language: "zh" | "en",
+  ): string {
+    const selectedContext = contextPackage.selectedContext
+      .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`)
+      .join("\n");
+    const overrides = ruleStack.activeOverrides.length > 0
+      ? ruleStack.activeOverrides
+        .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`)
+        .join("\n")
+      : "- none";
+
+    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}
+
+### 已选上下文
+${selectedContext || "- none"}
+
+### 规则栈
+- 硬护栏:${ruleStack.sections.hard.join("、") || "(无)"}
+- 软约束:${ruleStack.sections.soft.join("、") || "(无)"}
+- 诊断规则:${ruleStack.sections.diagnostic.join("、") || "(无)"}
+
+### 当前覆盖
+${overrides}\n`;
+  }
+
   private buildMemoryGoal(chapterTitle: string | undefined, chapterContent: string): string {
     return [chapterTitle ?? "", chapterContent.slice(0, 1500)]
       .filter((part) => part.trim().length > 0)

+ 13 - 0
packages/core/src/pipeline/runner.ts

@@ -1108,6 +1108,7 @@ export class PipelineRunner {
       output,
       finalContent,
       lengthSpec.countingMode,
+      reducedControlInput,
     );
     const longSpanFatigue = await analyzeLongSpanFatigue({
       bookDir,
@@ -1547,6 +1548,7 @@ ${matrix}`,
       for (let i = startFrom - 1; i < input.chapters.length; i++) {
         const ch = input.chapters[i]!;
         const chapterNumber = i + 1;
+        const governedInput = await this.prepareWriteInput(book, bookDir, chapterNumber);
 
         log?.info(this.localize(resolvedLanguage, {
           zh: `分析章节 ${chapterNumber}/${input.chapters.length}:${ch.title}...`,
@@ -1560,6 +1562,9 @@ ${matrix}`,
           chapterNumber,
           chapterContent: ch.content,
           chapterTitle: ch.title,
+          chapterIntent: governedInput.chapterIntent,
+          contextPackage: governedInput.contextPackage,
+          ruleStack: governedInput.ruleStack,
         });
 
         // Save chapter file + core truth files (state, ledger, hooks)
@@ -1648,6 +1653,11 @@ ${matrix}`,
     output: WriteChapterOutput,
     finalContent: string,
     countingMode: Parameters<typeof countChapterLength>[1],
+    reducedControlInput?: {
+      chapterIntent: string;
+      contextPackage: ContextPackage;
+      ruleStack: RuleStack;
+    },
   ): Promise<WriteChapterOutput> {
     if (finalContent === output.content) {
       return output;
@@ -1660,6 +1670,9 @@ ${matrix}`,
       chapterNumber,
       chapterContent: finalContent,
       chapterTitle: output.title,
+      chapterIntent: reducedControlInput?.chapterIntent,
+      contextPackage: reducedControlInput?.contextPackage,
+      ruleStack: reducedControlInput?.ruleStack,
     });
 
     return {