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

feat: route chapter writing through planner and composer

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

+ 127 - 2
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -1,10 +1,12 @@
 import { afterEach, describe, expect, it, vi } from "vitest";
 import { afterEach, describe, expect, it, vi } from "vitest";
-import { mkdtemp, mkdir, readFile, rm, stat } from "node:fs/promises";
+import { mkdtemp, mkdir, readFile, rm, stat, 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 { PipelineRunner } from "../pipeline/runner.js";
 import { PipelineRunner } from "../pipeline/runner.js";
 import { StateManager } from "../state/manager.js";
 import { StateManager } from "../state/manager.js";
 import { ArchitectAgent } from "../agents/architect.js";
 import { ArchitectAgent } from "../agents/architect.js";
+import { PlannerAgent } from "../agents/planner.js";
+import { ComposerAgent } from "../agents/composer.js";
 import { WriterAgent, type WriteChapterOutput } from "../agents/writer.js";
 import { WriterAgent, type WriteChapterOutput } from "../agents/writer.js";
 import { ContinuityAuditor, type AuditIssue, type AuditResult } from "../agents/continuity.js";
 import { ContinuityAuditor, type AuditIssue, type AuditResult } from "../agents/continuity.js";
 import { ReviserAgent, type ReviseOutput } from "../agents/reviser.js";
 import { ReviserAgent, type ReviseOutput } from "../agents/reviser.js";
@@ -85,7 +87,9 @@ function createAnalyzedOutput(overrides: Partial<WriteChapterOutput> = {}): Writ
   });
   });
 }
 }
 
 
-async function createRunnerFixture(): Promise<{
+async function createRunnerFixture(
+  configOverrides: Partial<ConstructorParameters<typeof PipelineRunner>[0]> = {},
+): Promise<{
   root: string;
   root: string;
   runner: PipelineRunner;
   runner: PipelineRunner;
   state: StateManager;
   state: StateManager;
@@ -124,6 +128,7 @@ async function createRunnerFixture(): Promise<{
     } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
     } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
     model: "test-model",
     model: "test-model",
     projectRoot: root,
     projectRoot: root,
+    ...configOverrides,
   });
   });
 
 
   return { root, runner, state, bookId };
   return { root, runner, state, bookId };
@@ -284,6 +289,126 @@ describe("PipelineRunner", () => {
     }
     }
   });
   });
 
 
+  it("routes writeDraft through planner and composer 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"),
+    ]);
+
+    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
+    const composeChapter = vi.spyOn(ComposerAgent.prototype, "composeChapter");
+    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, "Ignore the guild chase and bring focus back to mentor conflict.");
+
+      expect(planChapter).toHaveBeenCalledTimes(1);
+      expect(composeChapter).toHaveBeenCalledTimes(1);
+
+      const writeInput = writeChapter.mock.calls[0]?.[0];
+      expect(writeInput?.externalContext).toBeUndefined();
+      expect(writeInput?.chapterIntent).toContain("# Chapter Intent");
+      expect(writeInput?.contextPackage?.selectedContext.length).toBeGreaterThan(0);
+      expect(writeInput?.ruleStack?.activeOverrides).toHaveLength(1);
+
+      const runtimeDir = join(state.bookDir(bookId), "story", "runtime");
+      await expect(stat(join(runtimeDir, "chapter-0001.intent.md"))).resolves.toBeTruthy();
+      await expect(stat(join(runtimeDir, "chapter-0001.context.json"))).resolves.toBeTruthy();
+      await expect(stat(join(runtimeDir, "chapter-0001.rule-stack.yaml"))).resolves.toBeTruthy();
+      await expect(stat(join(runtimeDir, "chapter-0001.trace.json"))).resolves.toBeTruthy();
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("routes writeNextChapter through planner and composer 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"),
+    ]);
+
+    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
+    const composeChapter = vi.spyOn(ComposerAgent.prototype, "composeChapter");
+    const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Governed pipeline draft.",
+        wordCount: "Governed pipeline draft.".length,
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+
+    try {
+      await runner.writeNextChapter(bookId);
+
+      expect(planChapter).toHaveBeenCalledTimes(1);
+      expect(composeChapter).toHaveBeenCalledTimes(1);
+      const writeInput = writeChapter.mock.calls[0]?.[0];
+      expect(writeInput?.chapterIntent).toContain("# Chapter Intent");
+      expect(writeInput?.contextPackage?.selectedContext.length).toBeGreaterThan(0);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("preserves the legacy fallback when input governance mode is legacy", async () => {
+    const { root, runner, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "legacy",
+      externalContext: "Legacy focus only.",
+    });
+
+    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
+    const composeChapter = vi.spyOn(ComposerAgent.prototype, "composeChapter");
+    const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Legacy draft body.",
+        wordCount: "Legacy draft body.".length,
+      }),
+    );
+
+    try {
+      await runner.writeDraft(bookId);
+
+      expect(planChapter).not.toHaveBeenCalled();
+      expect(composeChapter).not.toHaveBeenCalled();
+
+      const writeInput = writeChapter.mock.calls[0]?.[0];
+      expect(writeInput?.externalContext).toBe("Legacy focus only.");
+      expect(writeInput?.chapterIntent).toBeUndefined();
+      expect(writeInput?.contextPackage).toBeUndefined();
+      expect(writeInput?.ruleStack).toBeUndefined();
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("uses the latest revised content as the input for follow-up spot-fix revisions", async () => {
   it("uses the latest revised content as the input for follow-up spot-fix revisions", async () => {
     const { root, runner, bookId } = await createRunnerFixture();
     const { root, runner, bookId } = await createRunnerFixture();
 
 

+ 134 - 35
packages/core/src/agents/writer.ts

@@ -9,6 +9,7 @@ import { parseSettlementOutput } from "./settler-parser.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { validatePostWrite, type PostWriteViolation } from "./post-write-validator.js";
 import { validatePostWrite, type PostWriteViolation } from "./post-write-validator.js";
 import { analyzeAITells } from "./ai-tells.js";
 import { analyzeAITells } from "./ai-tells.js";
+import type { ChapterTrace, ContextPackage, RuleStack } from "../models/input-governance.js";
 import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
 import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
 import { extractPOVFromOutline, filterMatrixByPOV, filterHooksByPOV } from "../utils/pov-filter.js";
 import { extractPOVFromOutline, filterMatrixByPOV, filterHooksByPOV } from "../utils/pov-filter.js";
 import { parseCreativeOutput } from "./writer-parser.js";
 import { parseCreativeOutput } from "./writer-parser.js";
@@ -20,6 +21,10 @@ export interface WriteChapterInput {
   readonly bookDir: string;
   readonly bookDir: string;
   readonly chapterNumber: number;
   readonly chapterNumber: number;
   readonly externalContext?: string;
   readonly externalContext?: string;
+  readonly chapterIntent?: string;
+  readonly contextPackage?: ContextPackage;
+  readonly ruleStack?: RuleStack;
+  readonly trace?: ChapterTrace;
   readonly wordCountOverride?: number;
   readonly wordCountOverride?: number;
   readonly temperatureOverride?: number;
   readonly temperatureOverride?: number;
 }
 }
@@ -112,41 +117,53 @@ export class WriterAgent extends BaseAgent {
       chapterNumber, "creative", fanficContext, resolvedLanguage,
       chapterNumber, "creative", fanficContext, resolvedLanguage,
     );
     );
 
 
-    // Smart context filtering: inject only relevant parts of truth files
-    const filteredHooks = filterHooks(hooks);
-    const filteredSummaries = filterSummaries(chapterSummaries, chapterNumber);
-    const filteredSubplots = filterSubplots(subplotBoard);
-    const filteredArcs = filterEmotionalArcs(emotionalArcs, chapterNumber);
-    const filteredMatrix = filterCharacterMatrix(characterMatrix, volumeOutline, bookRules?.protagonist?.name);
-
-    // POV-aware filtering: limit context to what the POV character knows
-    const povCharacter = extractPOVFromOutline(volumeOutline, chapterNumber);
-    const povFilteredMatrix = povCharacter
-      ? filterMatrixByPOV(filteredMatrix, povCharacter)
-      : filteredMatrix;
-    const povFilteredHooks = povCharacter
-      ? filterHooksByPOV(filteredHooks, povCharacter, chapterSummaries)
-      : filteredHooks;
-
-    const creativeUserPrompt = this.buildUserPrompt({
-      chapterNumber,
-      storyBible,
-      volumeOutline,
-      currentState,
-      ledger: genreProfile.numericalSystem ? ledger : "",
-      hooks: povFilteredHooks,
-      recentChapters,
-      wordCount: input.wordCountOverride ?? book.chapterWordCount,
-      externalContext: input.externalContext,
-      chapterSummaries: filteredSummaries,
-      subplotBoard: filteredSubplots,
-      emotionalArcs: filteredArcs,
-      characterMatrix: povFilteredMatrix,
-      dialogueFingerprints,
-      relevantSummaries,
-      parentCanon: hasParentCanon ? parentCanon : undefined,
-      language: book.language ?? genreProfile.language,
-    });
+    const creativeUserPrompt = input.chapterIntent && input.contextPackage && input.ruleStack
+      ? this.buildGovernedUserPrompt({
+          chapterNumber,
+          chapterIntent: input.chapterIntent,
+          contextPackage: input.contextPackage,
+          ruleStack: input.ruleStack,
+          trace: input.trace,
+          wordCount: input.wordCountOverride ?? book.chapterWordCount,
+          language: book.language ?? genreProfile.language,
+        })
+      : (() => {
+          // Smart context filtering: inject only relevant parts of truth files
+          const filteredHooks = filterHooks(hooks);
+          const filteredSummaries = filterSummaries(chapterSummaries, chapterNumber);
+          const filteredSubplots = filterSubplots(subplotBoard);
+          const filteredArcs = filterEmotionalArcs(emotionalArcs, chapterNumber);
+          const filteredMatrix = filterCharacterMatrix(characterMatrix, volumeOutline, bookRules?.protagonist?.name);
+
+          // POV-aware filtering: limit context to what the POV character knows
+          const povCharacter = extractPOVFromOutline(volumeOutline, chapterNumber);
+          const povFilteredMatrix = povCharacter
+            ? filterMatrixByPOV(filteredMatrix, povCharacter)
+            : filteredMatrix;
+          const povFilteredHooks = povCharacter
+            ? filterHooksByPOV(filteredHooks, povCharacter, chapterSummaries)
+            : filteredHooks;
+
+          return this.buildUserPrompt({
+            chapterNumber,
+            storyBible,
+            volumeOutline,
+            currentState,
+            ledger: genreProfile.numericalSystem ? ledger : "",
+            hooks: povFilteredHooks,
+            recentChapters,
+            wordCount: input.wordCountOverride ?? book.chapterWordCount,
+            externalContext: input.externalContext,
+            chapterSummaries: filteredSummaries,
+            subplotBoard: filteredSubplots,
+            emotionalArcs: filteredArcs,
+            characterMatrix: povFilteredMatrix,
+            dialogueFingerprints,
+            relevantSummaries,
+            parentCanon: hasParentCanon ? parentCanon : undefined,
+            language: book.language ?? genreProfile.language,
+          });
+        })();
 
 
     const creativeTemperature = input.temperatureOverride ?? 0.7;
     const creativeTemperature = input.temperatureOverride ?? 0.7;
 
 
@@ -456,6 +473,88 @@ ${params.volumeOutline}
 - 剧情推进速度必须与卷纲规划的章节跨度匹配:如果卷纲规划某段剧情跨5章,不得在1-2章内讲完
 - 剧情推进速度必须与卷纲规划的章节跨度匹配:如果卷纲规划某段剧情跨5章,不得在1-2章内讲完
 - PRE_WRITE_CHECK中必须明确标注本章对应的卷纲节点
 - PRE_WRITE_CHECK中必须明确标注本章对应的卷纲节点
 
 
+要求:
+- 正文不少于${params.wordCount}字
+- 先输出写作自检表,再写正文
+      - 只需输出 PRE_WRITE_CHECK、CHAPTER_TITLE、CHAPTER_CONTENT 三个区块`;
+  }
+
+  private buildGovernedUserPrompt(params: {
+    readonly chapterNumber: number;
+    readonly chapterIntent: string;
+    readonly contextPackage: ContextPackage;
+    readonly ruleStack: RuleStack;
+    readonly trace?: ChapterTrace;
+    readonly wordCount: number;
+    readonly language?: "zh" | "en";
+  }): string {
+    const contextSections = params.contextPackage.selectedContext
+      .map((entry) => [
+        `### ${entry.source}`,
+        `- reason: ${entry.reason}`,
+        entry.excerpt ? `- excerpt: ${entry.excerpt}` : "",
+      ].filter(Boolean).join("\n"))
+      .join("\n\n");
+
+    const overrideLines = params.ruleStack.activeOverrides.length > 0
+      ? params.ruleStack.activeOverrides
+        .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`)
+        .join("\n")
+      : "- none";
+
+    const diagnosticLines = params.ruleStack.sections.diagnostic.length > 0
+      ? params.ruleStack.sections.diagnostic.join(", ")
+      : "none";
+
+    const traceNotes = params.trace && params.trace.notes.length > 0
+      ? params.trace.notes.map((note) => `- ${note}`).join("\n")
+      : "- none";
+
+    if (params.language === "en") {
+      return `Write chapter ${params.chapterNumber}.
+
+## Chapter Intent
+${params.chapterIntent}
+
+## Selected Context
+${contextSections || "(none)"}
+
+## Rule Stack
+- Hard: ${params.ruleStack.sections.hard.join(", ") || "(none)"}
+- Soft: ${params.ruleStack.sections.soft.join(", ") || "(none)"}
+- Diagnostic: ${diagnosticLines}
+
+## Active Overrides
+${overrideLines}
+
+## Trace Notes
+${traceNotes}
+
+Requirements:
+- Chapter body must be at least ${params.wordCount} words
+- Output PRE_WRITE_CHECK first, then the chapter
+- Output only PRE_WRITE_CHECK, CHAPTER_TITLE, and CHAPTER_CONTENT blocks`;
+    }
+
+    return `请续写第${params.chapterNumber}章。
+
+## 本章意图
+${params.chapterIntent}
+
+## 已选上下文
+${contextSections || "(无)"}
+
+## 规则栈
+- 硬护栏:${params.ruleStack.sections.hard.join("、") || "(无)"}
+- 软约束:${params.ruleStack.sections.soft.join("、") || "(无)"}
+- 诊断规则:${diagnosticLines}
+
+## 当前覆盖
+${overrideLines}
+
+## 追踪说明
+${traceNotes}
+
 要求:
 要求:
 - 正文不少于${params.wordCount}字
 - 正文不少于${params.wordCount}字
 - 先输出写作自检表,再写正文
 - 先输出写作自检表,再写正文

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

@@ -1,7 +1,7 @@
 // Models
 // Models
 export { type BookConfig, type Platform, type Genre, type BookStatus, type FanficMode, BookConfigSchema, PlatformSchema, GenreSchema, BookStatusSchema, FanficModeSchema } from "./models/book.js";
 export { type BookConfig, type Platform, type Genre, type BookStatus, type FanficMode, BookConfigSchema, PlatformSchema, GenreSchema, BookStatusSchema, FanficModeSchema } from "./models/book.js";
 export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js";
 export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js";
-export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type AgentLLMOverride, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema } from "./models/project.js";
+export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type AgentLLMOverride, type InputGovernanceMode, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema, InputGovernanceModeSchema } from "./models/project.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
 export { type GenreProfile, type ParsedGenreProfile, GenreProfileSchema, parseGenreProfile } from "./models/genre-profile.js";
 export { type GenreProfile, type ParsedGenreProfile, GenreProfileSchema, parseGenreProfile } from "./models/genre-profile.js";
 export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules } from "./models/book-rules.js";
 export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules } from "./models/book-rules.js";
@@ -16,6 +16,7 @@ export {
   type RuleLayer,
   type RuleLayer,
   type OverrideEdge,
   type OverrideEdge,
   type ActiveOverride,
   type ActiveOverride,
+  type RuleStackSections,
   type RuleStack,
   type RuleStack,
   type ChapterTrace,
   type ChapterTrace,
   ChapterConflictSchema,
   ChapterConflictSchema,
@@ -26,9 +27,12 @@ export {
   RuleLayerSchema,
   RuleLayerSchema,
   OverrideEdgeSchema,
   OverrideEdgeSchema,
   ActiveOverrideSchema,
   ActiveOverrideSchema,
+  RuleStackSectionsSchema,
   RuleStackSchema,
   RuleStackSchema,
   ChapterTraceSchema,
   ChapterTraceSchema,
 } from "./models/input-governance.js";
 } from "./models/input-governance.js";
+export { PlannerAgent, type PlanChapterInput, type PlanChapterOutput } from "./agents/planner.js";
+export { ComposerAgent, type ComposeChapterInput, type ComposeChapterOutput } from "./agents/composer.js";
 
 
 // LLM
 // LLM
 export { createLLMClient, chatCompletion, chatWithTools, createStreamMonitor, PartialResponseError, type LLMClient, type LLMResponse, type LLMMessage, type ToolDefinition, type ToolCall, type AgentMessage, type ChatWithToolsResult, type StreamProgress, type OnStreamProgress } from "./llm/provider.js";
 export { createLLMClient, chatCompletion, chatWithTools, createStreamMonitor, PartialResponseError, type LLMClient, type LLMResponse, type LLMMessage, type ToolDefinition, type ToolCall, type AgentMessage, type ChatWithToolsResult, type StreamProgress, type OnStreamProgress } from "./llm/provider.js";

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

@@ -69,6 +69,9 @@ export const AgentLLMOverrideSchema = z.object({
 
 
 export type AgentLLMOverride = z.infer<typeof AgentLLMOverrideSchema>;
 export type AgentLLMOverride = z.infer<typeof AgentLLMOverrideSchema>;
 
 
+export const InputGovernanceModeSchema = z.enum(["legacy", "v2"]);
+export type InputGovernanceMode = z.infer<typeof InputGovernanceModeSchema>;
+
 const ModelOverrideValueSchema = z.union([z.string(), AgentLLMOverrideSchema]);
 const ModelOverrideValueSchema = z.union([z.string(), AgentLLMOverrideSchema]);
 
 
 export const ProjectConfigSchema = z.object({
 export const ProjectConfigSchema = z.object({
@@ -79,6 +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"),
   daemon: z.object({
   daemon: z.object({
     schedule: z.object({
     schedule: z.object({
       radarCron: z.string().default("0 */6 * * *"),
       radarCron: z.string().default("0 */6 * * *"),

+ 53 - 4
packages/core/src/pipeline/runner.ts

@@ -3,10 +3,12 @@ import { chatCompletion, createLLMClient } from "../llm/provider.js";
 import type { Logger } from "../utils/logger.js";
 import type { Logger } from "../utils/logger.js";
 import type { BookConfig, FanficMode } from "../models/book.js";
 import type { BookConfig, FanficMode } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import type { ChapterMeta } from "../models/chapter.js";
-import type { NotifyChannel, LLMConfig, AgentLLMOverride } from "../models/project.js";
+import type { NotifyChannel, LLMConfig, AgentLLMOverride, InputGovernanceMode } from "../models/project.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import { ArchitectAgent } from "../agents/architect.js";
 import { ArchitectAgent } from "../agents/architect.js";
-import { WriterAgent, type WriteChapterOutput } from "../agents/writer.js";
+import { PlannerAgent } from "../agents/planner.js";
+import { ComposerAgent } from "../agents/composer.js";
+import { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "../agents/writer.js";
 import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
 import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
 import { ContinuityAuditor } from "../agents/continuity.js";
 import { ContinuityAuditor } from "../agents/continuity.js";
 import { ReviserAgent, type ReviseMode } from "../agents/reviser.js";
 import { ReviserAgent, type ReviseMode } from "../agents/reviser.js";
@@ -34,6 +36,7 @@ export interface PipelineConfig {
   readonly radarSources?: ReadonlyArray<RadarSource>;
   readonly radarSources?: ReadonlyArray<RadarSource>;
   readonly externalContext?: string;
   readonly externalContext?: string;
   readonly modelOverrides?: Record<string, string | AgentLLMOverride>;
   readonly modelOverrides?: Record<string, string | AgentLLMOverride>;
+  readonly inputGovernanceMode?: InputGovernanceMode;
   readonly logger?: Logger;
   readonly logger?: Logger;
   readonly onStreamProgress?: OnStreamProgress;
   readonly onStreamProgress?: OnStreamProgress;
 }
 }
@@ -270,6 +273,12 @@ export class PipelineRunner {
       const book = await this.state.loadBookConfig(bookId);
       const book = await this.state.loadBookConfig(bookId);
       const bookDir = this.state.bookDir(bookId);
       const bookDir = this.state.bookDir(bookId);
       const chapterNumber = await this.state.getNextChapterNumber(bookId);
       const chapterNumber = await this.state.getNextChapterNumber(bookId);
+      const writeInput = await this.prepareWriteInput(
+        book,
+        bookDir,
+        chapterNumber,
+        context ?? this.config.externalContext,
+      );
 
 
       const { profile: gp } = await this.loadGenreProfile(book.genre);
       const { profile: gp } = await this.loadGenreProfile(book.genre);
 
 
@@ -278,7 +287,7 @@ export class PipelineRunner {
         book,
         book,
         bookDir,
         bookDir,
         chapterNumber,
         chapterNumber,
-        externalContext: context ?? this.config.externalContext,
+        ...writeInput,
         ...(wordCount ? { wordCountOverride: wordCount } : {}),
         ...(wordCount ? { wordCountOverride: wordCount } : {}),
       });
       });
 
 
@@ -543,6 +552,12 @@ export class PipelineRunner {
     const book = await this.state.loadBookConfig(bookId);
     const book = await this.state.loadBookConfig(bookId);
     const bookDir = this.state.bookDir(bookId);
     const bookDir = this.state.bookDir(bookId);
     const chapterNumber = await this.state.getNextChapterNumber(bookId);
     const chapterNumber = await this.state.getNextChapterNumber(bookId);
+    const writeInput = await this.prepareWriteInput(
+      book,
+      bookDir,
+      chapterNumber,
+      this.config.externalContext,
+    );
     const { profile: gp } = await this.loadGenreProfile(book.genre);
     const { profile: gp } = await this.loadGenreProfile(book.genre);
 
 
     // 1. Write chapter
     // 1. Write chapter
@@ -551,7 +566,7 @@ export class PipelineRunner {
       book,
       book,
       bookDir,
       bookDir,
       chapterNumber,
       chapterNumber,
-      externalContext: this.config.externalContext,
+      ...writeInput,
       ...(wordCount ? { wordCountOverride: wordCount } : {}),
       ...(wordCount ? { wordCountOverride: wordCount } : {}),
       ...(temperatureOverride ? { temperatureOverride } : {}),
       ...(temperatureOverride ? { temperatureOverride } : {}),
     });
     });
@@ -1144,6 +1159,40 @@ ${matrix}`,
   // Helpers
   // Helpers
   // ---------------------------------------------------------------------------
   // ---------------------------------------------------------------------------
 
 
+  private async prepareWriteInput(
+    book: BookConfig,
+    bookDir: string,
+    chapterNumber: number,
+    externalContext?: string,
+  ): Promise<Pick<WriteChapterInput, "externalContext" | "chapterIntent" | "contextPackage" | "ruleStack" | "trace">> {
+    if ((this.config.inputGovernanceMode ?? "legacy") === "legacy") {
+      return { externalContext };
+    }
+
+    const planner = new PlannerAgent(this.agentCtxFor("planner", book.id));
+    const plan = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber,
+      externalContext,
+    });
+
+    const composer = new ComposerAgent(this.agentCtxFor("composer", book.id));
+    const composed = await composer.composeChapter({
+      book,
+      bookDir,
+      chapterNumber,
+      plan,
+    });
+
+    return {
+      chapterIntent: plan.intentMarkdown,
+      contextPackage: composed.contextPackage,
+      ruleStack: composed.ruleStack,
+      trace: composed.trace,
+    };
+  }
+
   private async emitWebhook(
   private async emitWebhook(
     event: WebhookEvent,
     event: WebhookEvent,
     bookId: string,
     bookId: string,