Преглед изворни кода

refactor(planner): route governed writing through chapter briefs

Ma пре 7 месеци
родитељ
комит
ae6ac012d5

+ 89 - 1
packages/core/src/__tests__/composer.test.ts

@@ -80,6 +80,18 @@ describe("ComposerAgent", () => {
           avoidNewHookFamilies: [],
         },
       },
+      brief: {
+        chapter: 4,
+        goal: "Bring the focus back to the mentor conflict.",
+        chapterType: "confrontation",
+        isGoldenOpening: false,
+        beatOutline: [
+          { phase: "opening", instruction: "Open inside the mentor argument." },
+          { phase: "development", instruction: "Bring the oath token back into the scene." },
+        ],
+        hookPlan: [],
+        propsAndSetting: ["broken oath token"],
+      },
       intentMarkdown: "# Chapter Intent\n",
       plannerInputs: [
         join(storyDir, "author_intent.md"),
@@ -112,7 +124,8 @@ describe("ComposerAgent", () => {
     });
 
     const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
-    expect(selectedSources.slice(0, 4)).toEqual([
+    expect(selectedSources.slice(0, 5)).toEqual([
+      "runtime/chapter_brief",
       "story/current_focus.md",
       "story/current_state.md",
       "story/story_bible.md",
@@ -568,4 +581,79 @@ describe("ComposerAgent", () => {
     expect(hookDebtEntry?.excerpt).toContain("River Camp");
     expect(hookDebtEntry?.excerpt).toContain("Trial Echo");
   });
+
+  it("includes brief-selected hook ids in hook debt retrieval even before hookAgenda is updated", async () => {
+    await Promise.all([
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "# Pending Hooks",
+          "",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| black-ring | 6 | mystery | open | 7 | 揭开黑戒来源 | 中程 | 这一根还没进旧 hookAgenda |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "# Chapter Summaries",
+          "",
+          "| 6 | Black Ring | Lin Yue | Black ring first surfaces | Pressure rises | black-ring seeded | uneasy | mystery |",
+          "| 7 | Wet Dock | Lin Yue | Ring clue points to the dock | Stakes rise | black-ring advanced | tense | pursuit |",
+          "",
+        ].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: 8,
+      plan: {
+        ...plan,
+        intent: {
+          ...plan.intent,
+          chapter: 8,
+          goal: "Follow the black ring pressure.",
+          hookAgenda: {
+            pressureMap: [],
+            mustAdvance: [],
+            eligibleResolve: [],
+            staleDebt: [],
+            avoidNewHookFamilies: [],
+          },
+        },
+        brief: {
+          chapter: 8,
+          goal: "Follow the black ring pressure.",
+          chapterType: "pursuit",
+          isGoldenOpening: false,
+          beatOutline: [
+            { phase: "opening", instruction: "Open with the ring clue already in motion." },
+          ],
+          hookPlan: [
+            {
+              hookId: "black-ring",
+              movement: "advance",
+              targetEffect: "Push the ring clue onto the dock in a way the reader can feel.",
+            },
+          ],
+          propsAndSetting: ["wet dock", "black ring"],
+        },
+      },
+    });
+
+    expect(result.contextPackage.selectedContext.map((entry) => entry.source)).toContain("runtime/hook_debt#black-ring");
+  });
 });

+ 36 - 0
packages/core/src/__tests__/persisted-governed-plan.test.ts

@@ -22,6 +22,23 @@ describe("persisted governed plan helpers", () => {
         "## Outline Node",
         "Track the mentor oath fallout.",
         "",
+        "## Chapter Brief",
+        "- chapterType: confrontation",
+        "- isGoldenOpening: true",
+        "- dormantReason: Hold the guild-route line for the next chapter.",
+        "",
+        "### Beat Outline",
+        "- opening: Open inside the oath confrontation.",
+        "- development: Push the token back into the argument.",
+        "- hook: End when the witness changes sides.",
+        "",
+        "### Hook Plan",
+        "- mentor-oath: advance -> Turn the oath debt into an immediate cost.",
+        "",
+        "### Props And Setting",
+        "- broken oath token",
+        "- rain-slick shrine steps",
+        "",
         "## Must Keep",
         "- Lin Yue keeps the oath token hidden.",
         "- Mentor debt stays unresolved.",
@@ -53,6 +70,25 @@ describe("persisted governed plan helpers", () => {
       ]);
       expect(plan?.intent.mustAvoid).toEqual(["Open a new guild-route mystery."]);
       expect(plan?.intent.styleEmphasis).toEqual(["restrained prose"]);
+      expect(plan?.brief).toEqual(expect.objectContaining({
+        chapterType: "confrontation",
+        isGoldenOpening: true,
+        dormantReason: "Hold the guild-route line for the next chapter.",
+      }));
+      expect(plan?.brief?.beatOutline).toEqual(expect.arrayContaining([
+        expect.objectContaining({ phase: "opening" }),
+        expect.objectContaining({ phase: "hook" }),
+      ]));
+      expect(plan?.brief?.hookPlan).toEqual([
+        expect.objectContaining({
+          hookId: "mentor-oath",
+          movement: "advance",
+        }),
+      ]);
+      expect(plan?.brief?.propsAndSetting).toEqual([
+        "broken oath token",
+        "rain-slick shrine steps",
+      ]);
       expect(plan?.intent.conflicts).toEqual([
         { type: "duty", resolution: "repay the oath without exposing the token" },
         { type: "trust", resolution: "keep the mentor debt personal" },

+ 64 - 7
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -8,7 +8,7 @@ import * as llmProvider from "../llm/provider.js";
 import { StateManager } from "../state/manager.js";
 import { ArchitectAgent } from "../agents/architect.js";
 import { PlannerAgent } from "../agents/planner.js";
-import { ComposerAgent } from "../agents/composer.js";
+import * as ComposerModule from "../agents/composer.js";
 import { WriterAgent, type WriteChapterOutput } from "../agents/writer.js";
 import { LengthNormalizerAgent } from "../agents/length-normalizer.js";
 import { ContinuityAuditor, type AuditIssue, type AuditResult } from "../agents/continuity.js";
@@ -525,8 +525,25 @@ describe("PipelineRunner", () => {
       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 originalPlanChapter = PlannerAgent.prototype.planChapter;
+    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter").mockImplementation(async function (this: PlannerAgent, input) {
+      const result = await originalPlanChapter.call(this, input);
+      return {
+        ...result,
+        brief: {
+          chapter: input.chapterNumber,
+          goal: result.intent.goal,
+          chapterType: "confrontation",
+          isGoldenOpening: true,
+          beatOutline: [
+            { phase: "opening", instruction: "Open on the conflict." },
+          ],
+          hookPlan: [],
+          propsAndSetting: ["broken oath token"],
+        },
+      };
+    });
+    const composeChapter = vi.spyOn(ComposerModule, "composeGovernedChapter");
     const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
       createWriterOutput({
         chapterNumber: 1,
@@ -544,6 +561,9 @@ describe("PipelineRunner", () => {
       const writeInput = writeChapter.mock.calls[0]?.[0];
       expect(writeInput?.externalContext).toBeUndefined();
       expect(writeInput?.chapterIntent).toContain("# Chapter Intent");
+      expect(writeInput?.chapterBrief).toEqual(expect.objectContaining({
+        chapter: 1,
+      }));
       expect(writeInput?.contextPackage?.selectedContext.length).toBeGreaterThan(0);
       expect(writeInput?.ruleStack?.activeOverrides).toHaveLength(1);
 
@@ -603,7 +623,24 @@ describe("PipelineRunner", () => {
       ),
     ]);
 
-    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
+    const originalPlanChapter = PlannerAgent.prototype.planChapter;
+    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter").mockImplementation(async function (this: PlannerAgent, input) {
+      const result = await originalPlanChapter.call(this, input);
+      return {
+        ...result,
+        brief: {
+          chapter: input.chapterNumber,
+          goal: result.intent.goal,
+          chapterType: "confrontation",
+          isGoldenOpening: true,
+          beatOutline: [
+            { phase: "opening", instruction: "Open on the conflict." },
+          ],
+          hookPlan: [],
+          propsAndSetting: ["broken oath token"],
+        },
+      };
+    });
     const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
       createWriterOutput({
         chapterNumber: 1,
@@ -1121,8 +1158,25 @@ describe("PipelineRunner", () => {
       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 originalPlanChapter = PlannerAgent.prototype.planChapter;
+    const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter").mockImplementation(async function (this: PlannerAgent, input) {
+      const result = await originalPlanChapter.call(this, input);
+      return {
+        ...result,
+        brief: {
+          chapter: input.chapterNumber,
+          goal: result.intent.goal,
+          chapterType: "confrontation",
+          isGoldenOpening: true,
+          beatOutline: [
+            { phase: "opening", instruction: "Open on the conflict." },
+          ],
+          hookPlan: [],
+          propsAndSetting: ["broken oath token"],
+        },
+      };
+    });
+    const composeChapter = vi.spyOn(ComposerModule, "composeGovernedChapter");
     const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
       createWriterOutput({
         chapterNumber: 1,
@@ -1145,6 +1199,9 @@ describe("PipelineRunner", () => {
       expect(composeChapter).toHaveBeenCalledTimes(1);
       const writeInput = writeChapter.mock.calls[0]?.[0];
       expect(writeInput?.chapterIntent).toContain("# Chapter Intent");
+      expect(writeInput?.chapterBrief).toEqual(expect.objectContaining({
+        chapter: 1,
+      }));
       expect(writeInput?.contextPackage?.selectedContext.length).toBeGreaterThan(0);
     } finally {
       await rm(root, { recursive: true, force: true });
@@ -1825,7 +1882,7 @@ describe("PipelineRunner", () => {
     });
 
     const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter");
-    const composeChapter = vi.spyOn(ComposerAgent.prototype, "composeChapter");
+    const composeChapter = vi.spyOn(ComposerModule, "composeGovernedChapter");
     const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
       createWriterOutput({
         chapterNumber: 1,

+ 70 - 1
packages/core/src/__tests__/planner.test.ts

@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
@@ -1479,4 +1479,73 @@ describe("PlannerAgent", () => {
     });
     expect(result5.intent.outlineNode).toContain("正面交锋");
   });
+
+  it("uses the LLM chapter brief when planner output is valid", async () => {
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    vi.spyOn(planner as unknown as { chat: (...args: unknown[]) => Promise<{ content: string }> }, "chat")
+      .mockResolvedValue({
+        content: JSON.stringify({
+          chapter: 3,
+          goal: "Force the warehouse confrontation before the guild escapes.",
+          chapterType: "confrontation",
+          isGoldenOpening: true,
+          beatOutline: [
+            { phase: "opening", instruction: "Open inside the warehouse argument." },
+            { phase: "development", instruction: "Push the unsigned transfer sheet into the scene." },
+            { phase: "hook", instruction: "End with the escape route changing hands." },
+          ],
+          hookPlan: [
+            {
+              hookId: "H019",
+              movement: "advance",
+              targetEffect: "Turn the mentor debt into an immediate cost.",
+            },
+          ],
+          dormantReason: "Hold the canal line until the next chapter.",
+          propsAndSetting: ["warehouse transfer sheet", "wet dock", "A Sheng"],
+        }),
+      });
+
+    const result = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 3,
+    });
+
+    expect(result.brief?.chapterType).toBe("confrontation");
+    expect(result.intent.goal).toContain("warehouse confrontation");
+    expect(result.intent.arcDirective).toContain("opening: Open inside the warehouse argument.");
+    expect(result.intent.sceneDirective).toContain("warehouse transfer sheet");
+    await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("## Chapter Brief");
+  });
+
+  it("falls back to the legacy planner when the LLM chapter brief is invalid", async () => {
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    vi.spyOn(planner as unknown as { chat: (...args: unknown[]) => Promise<{ content: string }> }, "chat")
+      .mockResolvedValue({
+        content: "{\"bad\":true}",
+      });
+
+    const result = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 3,
+    });
+
+    expect(result.brief).toBeUndefined();
+    expect(result.intent.goal).toContain("merchant guild's escape route");
+    await expect(readFile(result.runtimePath, "utf-8")).resolves.not.toContain("## Chapter Brief");
+  });
 });

+ 174 - 177
packages/core/src/agents/composer.ts

@@ -1,12 +1,9 @@
-import { readFile, readdir, writeFile, mkdir } from "node:fs/promises";
+import { readFile, readdir, mkdir } from "node:fs/promises";
 import { dirname, join } from "node:path";
-import yaml from "js-yaml";
 import { BaseAgent } from "./base.js";
 import type { BookConfig } from "../models/book.js";
 import {
-  ChapterTraceSchema,
   ContextPackageSchema,
-  RuleStackSchema,
   type ChapterTrace,
   type ContextPackage,
   type RuleStack,
@@ -16,6 +13,11 @@ import {
   parseChapterSummariesMarkdown,
   retrieveMemorySelection,
 } from "../utils/memory-retrieval.js";
+import {
+  buildGovernedRuleStack,
+  buildGovernedTrace,
+} from "../utils/context-assembly.js";
+import { writeGovernedRuntimeArtifacts } from "../utils/runtime-writer.js";
 
 export interface ComposeChapterInput {
   readonly book: BookConfig;
@@ -33,122 +35,116 @@ export interface ComposeChapterOutput {
   readonly tracePath: string;
 }
 
+export async function composeGovernedChapter(input: ComposeChapterInput): Promise<ComposeChapterOutput> {
+  const storyDir = join(input.bookDir, "story");
+  const runtimeDir = join(storyDir, "runtime");
+  await mkdir(runtimeDir, { recursive: true });
+
+  const selectedContext = await collectSelectedContext(
+    storyDir,
+    input.plan,
+    input.book.language ?? "zh",
+  );
+  const contextPackage = ContextPackageSchema.parse({
+    chapter: input.chapterNumber,
+    selectedContext,
+  });
+
+  const ruleStack = buildGovernedRuleStack(input.plan, input.chapterNumber);
+  const trace = buildGovernedTrace({
+    chapterNumber: input.chapterNumber,
+    plan: input.plan,
+    contextPackage,
+    composerInputs: [input.plan.runtimePath],
+  });
+  const {
+    contextPath,
+    ruleStackPath,
+    tracePath,
+  } = await writeGovernedRuntimeArtifacts({
+    runtimeDir,
+    chapterNumber: input.chapterNumber,
+    contextPackage,
+    ruleStack,
+    trace,
+  });
+
+  return {
+    contextPackage,
+    ruleStack,
+    trace,
+    contextPath,
+    ruleStackPath,
+    tracePath,
+  };
+}
+
 export class ComposerAgent extends BaseAgent {
   get name(): string {
     return "composer";
   }
 
   async composeChapter(input: ComposeChapterInput): Promise<ComposeChapterOutput> {
-    const storyDir = join(input.bookDir, "story");
-    const runtimeDir = join(storyDir, "runtime");
-    await mkdir(runtimeDir, { recursive: true });
-
-    const selectedContext = await this.collectSelectedContext(
-      storyDir,
-      input.plan,
-      input.book.language ?? "zh",
-    );
-    const contextPackage = ContextPackageSchema.parse({
-      chapter: input.chapterNumber,
-      selectedContext,
-    });
-
-    const ruleStack = RuleStackSchema.parse({
-      layers: [
-        { id: "L1", name: "hard_facts", precedence: 100, scope: "global" },
-        { id: "L2", name: "author_intent", precedence: 80, scope: "book" },
-        { id: "L3", name: "planning", precedence: 60, scope: "arc" },
-        { id: "L4", name: "current_task", precedence: 70, scope: "local" },
-      ],
-      sections: {
-        hard: ["story_bible", "current_state", "book_rules"],
-        soft: ["author_intent", "current_focus", "volume_outline"],
-        diagnostic: ["anti_ai_checks", "continuity_audit", "style_regression_checks"],
-      },
-      overrideEdges: [
-        { from: "L4", to: "L3", allowed: true, scope: "current_chapter" },
-        { from: "L4", to: "L2", allowed: false, scope: "current_chapter" },
-        { from: "L4", to: "L1", allowed: false, scope: "current_chapter" },
-      ],
-      activeOverrides: input.plan.intent.conflicts.map((conflict) => ({
-        from: "L4",
-        to: "L3",
-        target: input.plan.intent.outlineNode ?? `chapter_${input.chapterNumber}`,
-        reason: conflict.resolution,
-      })),
-    });
-
-    const trace = ChapterTraceSchema.parse({
-      chapter: input.chapterNumber,
-      plannerInputs: input.plan.plannerInputs,
-      composerInputs: [input.plan.runtimePath],
-      selectedSources: contextPackage.selectedContext.map((entry) => entry.source),
-      notes: input.plan.intent.conflicts.map((conflict) => conflict.resolution),
-    });
-
-    const chapterSlug = `chapter-${String(input.chapterNumber).padStart(4, "0")}`;
-    const contextPath = join(runtimeDir, `${chapterSlug}.context.json`);
-    const ruleStackPath = join(runtimeDir, `${chapterSlug}.rule-stack.yaml`);
-    const tracePath = join(runtimeDir, `${chapterSlug}.trace.json`);
-
-    await Promise.all([
-      writeFile(contextPath, JSON.stringify(contextPackage, null, 2), "utf-8"),
-      writeFile(ruleStackPath, yaml.dump(ruleStack, { lineWidth: 120 }), "utf-8"),
-      writeFile(tracePath, JSON.stringify(trace, null, 2), "utf-8"),
-    ]);
-
-    return {
-      contextPackage,
-      ruleStack,
-      trace,
-      contextPath,
-      ruleStackPath,
-      tracePath,
-    };
+    return composeGovernedChapter(input);
   }
+}
+
+async function collectSelectedContext(
+  storyDir: string,
+  plan: PlanChapterOutput,
+  language: "zh" | "en",
+): Promise<ContextPackage["selectedContext"]> {
+    const chapterBriefEntry = plan.brief
+      ? [{
+          source: "runtime/chapter_brief",
+          reason: "Carry the planner's concrete chapter execution brief directly into governed writing.",
+          excerpt: [
+            `type=${plan.brief.chapterType}`,
+            ...plan.brief.beatOutline.map((beat) => `${beat.phase}: ${beat.instruction}`),
+            ...(plan.brief.propsAndSetting.length > 0
+              ? [`props=${plan.brief.propsAndSetting.join(", ")}`]
+              : []),
+          ].join(" | "),
+        }]
+      : [];
 
-  private async collectSelectedContext(
-    storyDir: string,
-    plan: PlanChapterOutput,
-    language: "zh" | "en",
-  ): Promise<ContextPackage["selectedContext"]> {
     const entries = await Promise.all([
-      this.maybeContextSource(storyDir, "current_focus.md", "Current task focus for this chapter."),
-      this.maybeContextSource(
+      maybeContextSource(storyDir, "current_focus.md", "Current task focus for this chapter."),
+      maybeContextSource(
         storyDir,
         "audit_drift.md",
         "Carry forward audit drift guidance from the previous chapter without polluting hard state facts.",
       ),
-      this.maybeContextSource(
+      maybeContextSource(
         storyDir,
         "current_state.md",
         "Preserve hard state facts referenced by mustKeep.",
         plan.intent.mustKeep,
       ),
-      this.maybeContextSource(
+      maybeContextSource(
         storyDir,
         "story_bible.md",
         "Preserve canon constraints referenced by mustKeep.",
         plan.intent.mustKeep,
       ),
-      this.maybeContextSource(
+      maybeContextSource(
         storyDir,
         "volume_outline.md",
         "Anchor the default planning node for this chapter.",
         plan.intent.outlineNode ? [plan.intent.outlineNode] : [],
       ),
-      this.maybeContextSource(
+      maybeContextSource(
         storyDir,
         "parent_canon.md",
         "Preserve parent canon constraints for governed continuation or fanfic writing.",
       ),
-      this.maybeContextSource(
+      maybeContextSource(
         storyDir,
         "fanfic_canon.md",
         "Preserve extracted fanfic canon constraints for governed writing.",
       ),
     ]);
-    const trailEntries = await this.buildRecentChapterTrailEntries(storyDir, plan.intent.chapter);
+    const trailEntries = await buildRecentChapterTrailEntries(storyDir, plan.intent.chapter);
 
     const planningAnchor = plan.intent.conflicts.length > 0 ? undefined : plan.intent.outlineNode;
     const memorySelection = await retrieveMemorySelection({
@@ -158,7 +154,7 @@ export class ComposerAgent extends BaseAgent {
       outlineNode: planningAnchor,
       mustKeep: plan.intent.mustKeep,
     });
-    const hookDebtEntries = await this.buildHookDebtEntries(
+    const hookDebtEntries = await buildHookDebtEntries(
       storyDir,
       plan,
       memorySelection.activeHooks,
@@ -173,7 +169,7 @@ export class ComposerAgent extends BaseAgent {
         .join(" | "),
     }));
     const factEntries = memorySelection.facts.map((fact) => ({
-      source: `story/current_state.md#${this.toFactAnchor(fact.predicate)}`,
+      source: `story/current_state.md#${toFactAnchor(fact.predicate)}`,
       reason: "Relevant current-state fact retrieved for the current chapter goal.",
       excerpt: `${fact.predicate} | ${fact.object}`,
     }));
@@ -191,6 +187,7 @@ export class ComposerAgent extends BaseAgent {
     }));
 
     return [
+      ...chapterBriefEntry,
       ...entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
       ...trailEntries,
       ...hookDebtEntries,
@@ -199,13 +196,13 @@ export class ComposerAgent extends BaseAgent {
       ...volumeSummaryEntries,
       ...hookEntries,
     ];
-  }
+}
 
-  private async buildRecentChapterTrailEntries(
-    storyDir: string,
-    chapterNumber: number,
-  ): Promise<ContextPackage["selectedContext"]> {
-    const content = await this.readFileOrDefault(join(storyDir, "chapter_summaries.md"));
+async function buildRecentChapterTrailEntries(
+  storyDir: string,
+  chapterNumber: number,
+): Promise<ContextPackage["selectedContext"]> {
+    const content = await readFileOrDefault(join(storyDir, "chapter_summaries.md"));
     if (!content || content === "(文件尚未创建)") {
       return [];
     }
@@ -243,7 +240,7 @@ export class ComposerAgent extends BaseAgent {
       });
     }
 
-    const endingTrail = await this.buildRecentEndingTrail(storyDir, chapterNumber);
+    const endingTrail = await buildRecentEndingTrail(storyDir, chapterNumber);
     if (endingTrail) {
       entries.push({
         source: "story/chapters#recent_endings",
@@ -253,12 +250,12 @@ export class ComposerAgent extends BaseAgent {
     }
 
     return entries;
-  }
+}
 
-  private async buildRecentEndingTrail(
-    storyDir: string,
-    chapterNumber: number,
-  ): Promise<string | undefined> {
+async function buildRecentEndingTrail(
+  storyDir: string,
+  chapterNumber: number,
+): Promise<string | undefined> {
     const chaptersDir = join(dirname(storyDir), "chapters");
     try {
       const files = await readdir(chaptersDir);
@@ -272,7 +269,7 @@ export class ComposerAgent extends BaseAgent {
       const endings: string[] = [];
       for (const entry of chapterFiles.reverse()) {
         const content = await readFile(join(chaptersDir, entry.file), "utf-8");
-        const lastLine = this.extractLastMeaningfulSentence(content);
+        const lastLine = extractLastMeaningfulSentence(content);
         if (lastLine) {
           endings.push(`ch${entry.num}: ${lastLine}`);
         }
@@ -281,21 +278,21 @@ export class ComposerAgent extends BaseAgent {
     } catch {
       return undefined;
     }
-  }
+}
 
-  private extractLastMeaningfulSentence(content: string): string | undefined {
+function extractLastMeaningfulSentence(content: string): string | undefined {
     const lines = content.split("\n").map((line) => line.trim()).filter((line) =>
       line.length > 5 && !line.startsWith("#") && !line.startsWith("|") && !line.startsWith("==="),
     );
     const last = lines.at(-1);
     if (!last) return undefined;
     return last.length > 60 ? last.slice(0, 57) + "..." : last;
-  }
+}
 
-  private async buildHookDebtEntries(
-    storyDir: string,
-    plan: PlanChapterOutput,
-    activeHooks: ReadonlyArray<{
+async function buildHookDebtEntries(
+  storyDir: string,
+  plan: PlanChapterOutput,
+  activeHooks: ReadonlyArray<{
       readonly hookId: string;
       readonly startChapter: number;
       readonly type: string;
@@ -305,10 +302,11 @@ export class ComposerAgent extends BaseAgent {
       readonly payoffTiming?: string;
       readonly notes: string;
     }>,
-    language: "zh" | "en",
-  ): Promise<ContextPackage["selectedContext"]> {
+  language: "zh" | "en",
+): Promise<ContextPackage["selectedContext"]> {
     const targetHookIds = [
       ...new Set([
+        ...(plan.brief?.hookPlan.map((entry) => entry.hookId) ?? []),
         ...plan.intent.hookAgenda.pressureMap.map((entry) => entry.hookId),
         ...plan.intent.hookAgenda.eligibleResolve,
         ...plan.intent.hookAgenda.mustAdvance,
@@ -320,7 +318,7 @@ export class ComposerAgent extends BaseAgent {
     }
 
     const summaries = parseChapterSummariesMarkdown(
-      await this.readFileOrDefault(join(storyDir, "chapter_summaries.md")),
+      await readFileOrDefault(join(storyDir, "chapter_summaries.md")),
     );
 
     return targetHookIds.flatMap((hookId) => {
@@ -329,15 +327,15 @@ export class ComposerAgent extends BaseAgent {
         return [];
       }
 
-      const seedSummary = this.findHookSummary(summaries, hook.hookId, hook.startChapter, "seed");
-      const latestSummary = this.findHookSummary(summaries, hook.hookId, hook.lastAdvancedChapter, "latest");
-      const role = this.describeHookAgendaRole(plan, hook.hookId, language);
+      const seedSummary = findHookSummary(summaries, hook.hookId, hook.startChapter, "seed");
+      const latestSummary = findHookSummary(summaries, hook.hookId, hook.lastAdvancedChapter, "latest");
+      const role = describeHookAgendaRole(plan, hook.hookId, language);
       const promise = hook.expectedPayoff || (language === "en" ? "(unspecified)" : "(未写明)");
       const seedBeat = seedSummary
-        ? this.renderHookDebtBeat(seedSummary)
+        ? renderHookDebtBeat(seedSummary)
         : (hook.notes || promise);
       const latestBeat = latestSummary && latestSummary !== seedSummary
-        ? this.renderHookDebtBeat(latestSummary)
+        ? renderHookDebtBeat(latestSummary)
         : undefined;
       const age = Math.max(0, plan.intent.chapter - Math.max(1, hook.startChapter));
 
@@ -361,26 +359,26 @@ export class ComposerAgent extends BaseAgent {
             ].filter(Boolean).join(" | "),
       }];
     });
-  }
+}
 
-  private async maybeContextSource(
-    storyDir: string,
-    fileName: string,
-    reason: string,
-    preferredExcerpts: ReadonlyArray<string> = [],
-  ): Promise<ContextPackage["selectedContext"][number] | null> {
+async function maybeContextSource(
+  storyDir: string,
+  fileName: string,
+  reason: string,
+  preferredExcerpts: ReadonlyArray<string> = [],
+): Promise<ContextPackage["selectedContext"][number] | null> {
     const path = join(storyDir, fileName);
-    const content = await this.readFileOrDefault(path);
+    const content = await readFileOrDefault(path);
     if (!content || content === "(文件尚未创建)") return null;
 
     return {
       source: `story/${fileName}`,
       reason,
-      excerpt: this.pickExcerpt(content, preferredExcerpts),
+      excerpt: pickExcerpt(content, preferredExcerpts),
     };
-  }
+}
 
-  private pickExcerpt(content: string, preferredExcerpts: ReadonlyArray<string>): string | undefined {
+function pickExcerpt(content: string, preferredExcerpts: ReadonlyArray<string>): string | undefined {
     for (const preferred of preferredExcerpts) {
       if (preferred && content.includes(preferred)) return preferred;
     }
@@ -389,73 +387,72 @@ export class ComposerAgent extends BaseAgent {
       .split("\n")
       .map((line) => line.trim())
       .find((line) => line.length > 0 && !line.startsWith("#"));
-  }
+}
 
-  private toFactAnchor(predicate: string): string {
+function toFactAnchor(predicate: string): string {
     return predicate
       .trim()
       .toLowerCase()
       .replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
       .replace(/^-+|-+$/g, "")
       || "fact";
-  }
+}
 
-  private async readFileOrDefault(path: string): Promise<string> {
-    try {
-      return await readFile(path, "utf-8");
-    } catch {
-      return "(文件尚未创建)";
-    }
+async function readFileOrDefault(path: string): Promise<string> {
+  try {
+    return await readFile(path, "utf-8");
+  } catch {
+    return "(文件尚未创建)";
   }
+}
 
-  private describeHookAgendaRole(
-    plan: PlanChapterOutput,
-    hookId: string,
-    language: "zh" | "en",
-  ): string {
-    if (plan.intent.hookAgenda.eligibleResolve.includes(hookId)) {
-      return language === "en" ? "payoff-ready debt" : "可兑现旧债";
-    }
-    if (plan.intent.hookAgenda.staleDebt.includes(hookId)) {
-      return language === "en" ? "high-pressure debt" : "高压旧债";
-    }
-    return language === "en" ? "mainline debt" : "主要旧债";
+function describeHookAgendaRole(
+  plan: PlanChapterOutput,
+  hookId: string,
+  language: "zh" | "en",
+): string {
+  if (plan.intent.hookAgenda.eligibleResolve.includes(hookId)) {
+    return language === "en" ? "payoff-ready debt" : "可兑现旧债";
   }
+  if (plan.intent.hookAgenda.staleDebt.includes(hookId)) {
+    return language === "en" ? "high-pressure debt" : "高压旧债";
+  }
+  return language === "en" ? "mainline debt" : "主要旧债";
+}
 
-  private findHookSummary(
-    summaries: ReadonlyArray<ReturnType<typeof parseChapterSummariesMarkdown>[number]>,
-    hookId: string,
-    chapter: number,
-    mode: "seed" | "latest",
-  ) {
-    const directChapterHit = summaries.find((summary) => summary.chapter === chapter);
-    const hookMentions = summaries.filter((summary) => this.summaryMentionsHook(summary, hookId));
-    if (mode === "seed") {
-      return hookMentions.find((summary) => summary.chapter === chapter)
-        ?? hookMentions.at(0)
-        ?? directChapterHit;
-    }
-
-    return [...hookMentions].reverse().find((summary) => summary.chapter === chapter)
-      ?? hookMentions.at(-1)
+function findHookSummary(
+  summaries: ReadonlyArray<ReturnType<typeof parseChapterSummariesMarkdown>[number]>,
+  hookId: string,
+  chapter: number,
+  mode: "seed" | "latest",
+) {
+  const directChapterHit = summaries.find((summary) => summary.chapter === chapter);
+  const hookMentions = summaries.filter((summary) => summaryMentionsHook(summary, hookId));
+  if (mode === "seed") {
+    return hookMentions.find((summary) => summary.chapter === chapter)
+      ?? hookMentions.at(0)
       ?? directChapterHit;
   }
 
-  private summaryMentionsHook(
-    summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
-    hookId: string,
-  ): boolean {
-    return [
-      summary.title,
-      summary.events,
-      summary.stateChanges,
-      summary.hookActivity,
-    ].some((text) => text.includes(hookId));
-  }
+  return [...hookMentions].reverse().find((summary) => summary.chapter === chapter)
+    ?? hookMentions.at(-1)
+    ?? directChapterHit;
+}
 
-  private renderHookDebtBeat(
-    summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
-  ): string {
-    return `ch${summary.chapter} ${summary.title} - ${summary.events || summary.hookActivity || summary.stateChanges || "(none)"}`;
-  }
+function summaryMentionsHook(
+  summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
+  hookId: string,
+): boolean {
+  return [
+    summary.title,
+    summary.events,
+    summary.stateChanges,
+    summary.hookActivity,
+  ].some((text) => text.includes(hookId));
+}
+
+function renderHookDebtBeat(
+  summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
+): string {
+  return `ch${summary.chapter} ${summary.title} - ${summary.events || summary.hookActivity || summary.stateChanges || "(none)"}`;
 }

+ 205 - 20
packages/core/src/agents/planner.ts

@@ -3,7 +3,13 @@ import { join } from "node:path";
 import { BaseAgent } from "./base.js";
 import type { BookConfig } from "../models/book.js";
 import { parseBookRules } from "../models/book-rules.js";
-import { ChapterIntentSchema, type ChapterConflict, type ChapterIntent } from "../models/input-governance.js";
+import {
+  ChapterBriefSchema,
+  ChapterIntentSchema,
+  type ChapterBrief,
+  type ChapterConflict,
+  type ChapterIntent,
+} from "../models/input-governance.js";
 import {
   parseChapterSummariesMarkdown,
   renderHookSnapshot,
@@ -14,7 +20,12 @@ import { buildPlannerHookAgenda } from "../utils/hook-agenda.js";
 import {
   gatherPlanningMaterials,
   loadPlanningSeedMaterials,
+  type PlanningMaterials,
 } from "../utils/planning-materials.js";
+import {
+  buildPlannerSystemPrompt,
+  buildPlannerUserPrompt,
+} from "./planner-prompts.js";
 
 export interface PlanChapterInput {
   readonly book: BookConfig;
@@ -25,6 +36,7 @@ export interface PlanChapterInput {
 
 export interface PlanChapterOutput {
   readonly intent: ChapterIntent;
+  readonly brief?: ChapterBrief;
   readonly intentMarkdown: string;
   readonly plannerInputs: ReadonlyArray<string>;
   readonly runtimePath: string;
@@ -86,33 +98,49 @@ export class PlannerAgent extends BaseAgent {
       chapterSummaries: materials.chapterSummariesRaw,
     });
 
-    // TODO(v10): delete this skill-to-mustKeep bridge once the LLM planner lands.
-    this.injectStructuralSkills({
-      chapterNumber: input.chapterNumber,
-      language: input.book.language ?? "zh",
-      platform: input.book.platform,
-      mustKeep,
-      mustAvoid,
-      styleEmphasis,
-      hookAgenda,
-      cadence: analyzeChapterCadence({
-        language: this.isChineseLanguage(input.book.language) ? "zh" : "en",
-        rows: parseChapterSummariesMarkdown(materials.chapterSummariesRaw)
-          .filter((s) => s.chapter < input.chapterNumber)
-          .sort((a, b) => a.chapter - b.chapter)
-          .slice(-4)
-          .map((s) => ({ chapter: s.chapter, title: s.title, mood: s.mood, chapterType: s.chapterType })),
-      }),
+    const brief = await this.tryPlanChapterBrief({
+      input,
+      outlineNode: planningAnchor,
+      materials,
     });
 
+    if (!brief) {
+      // TODO(v10): delete this skill-to-mustKeep bridge once the LLM planner lands.
+      this.injectStructuralSkills({
+        chapterNumber: input.chapterNumber,
+        language: input.book.language ?? "zh",
+        platform: input.book.platform,
+        mustKeep,
+        mustAvoid,
+        styleEmphasis,
+        hookAgenda,
+        cadence: analyzeChapterCadence({
+          language: this.isChineseLanguage(input.book.language) ? "zh" : "en",
+          rows: parseChapterSummariesMarkdown(materials.chapterSummariesRaw)
+            .filter((s) => s.chapter < input.chapterNumber)
+            .sort((a, b) => a.chapter - b.chapter)
+            .slice(-4)
+            .map((s) => ({ chapter: s.chapter, title: s.title, mood: s.mood, chapterType: s.chapterType })),
+        }),
+      });
+    }
+
     const intent = ChapterIntentSchema.parse({
       chapter: input.chapterNumber,
-      goal,
+      goal: brief?.goal ?? goal,
       outlineNode,
       ...directives,
       mustKeep,
       mustAvoid,
-      styleEmphasis,
+      styleEmphasis: brief
+        ? this.mergeBriefStyleEmphasis(styleEmphasis, brief)
+        : styleEmphasis,
+      sceneDirective: brief
+        ? this.buildSceneDirectiveFromBrief(brief)
+        : directives.sceneDirective,
+      arcDirective: brief
+        ? this.buildArcDirectiveFromBrief(brief)
+        : directives.arcDirective,
       conflicts,
       hookAgenda,
     });
@@ -120,6 +148,7 @@ export class PlannerAgent extends BaseAgent {
     const runtimePath = join(runtimeDir, `chapter-${String(input.chapterNumber).padStart(4, "0")}.intent.md`);
     const intentMarkdown = this.renderIntentMarkdown(
       intent,
+      brief ?? undefined,
       input.book.language ?? "zh",
       renderHookSnapshot(memorySelection.hooks, input.book.language ?? "zh"),
       renderSummarySnapshot(memorySelection.summaries, input.book.language ?? "zh"),
@@ -129,12 +158,75 @@ export class PlannerAgent extends BaseAgent {
 
     return {
       intent,
+      brief: brief ?? undefined,
       intentMarkdown,
       plannerInputs: materials.plannerInputs,
       runtimePath,
     };
   }
 
+  private async tryPlanChapterBrief(params: {
+    readonly input: PlanChapterInput;
+    readonly outlineNode: string | undefined;
+    readonly materials: PlanningMaterials;
+  }): Promise<ChapterBrief | null> {
+    try {
+      const language = this.isChineseLanguage(params.input.book.language) ? "zh" : "en";
+      const response = await this.chat([
+        {
+          role: "system",
+          content: buildPlannerSystemPrompt(language),
+        },
+        {
+          role: "user",
+          content: buildPlannerUserPrompt({
+            chapterNumber: params.input.chapterNumber,
+            targetChapters: params.input.book.targetChapters,
+            genreName: params.input.book.genre,
+            language,
+            materials: {
+              ...params.materials,
+              outlineNode: params.outlineNode,
+            },
+          }),
+        },
+      ], {
+        temperature: 0.2,
+        maxTokens: 1600,
+      });
+
+      const parsed = this.tryParseChapterBrief(response.content);
+      if (!parsed) {
+        throw new Error("Planner brief output was not valid JSON.");
+      }
+      return parsed;
+    } catch (error) {
+      const detail = error instanceof Error ? error.message : String(error);
+      this.log?.warn(`[planner] Falling back to legacy planner: ${detail}`);
+      return null;
+    }
+  }
+
+  private tryParseChapterBrief(text: string): ChapterBrief | null {
+    const direct = this.tryParseExactChapterBrief(text);
+    if (direct) {
+      return direct;
+    }
+    const candidate = extractBalancedJsonObject(text);
+    if (!candidate) {
+      return null;
+    }
+    return this.tryParseExactChapterBrief(candidate);
+  }
+
+  private tryParseExactChapterBrief(text: string): ChapterBrief | null {
+    try {
+      return ChapterBriefSchema.parse(JSON.parse(text));
+    } catch {
+      return null;
+    }
+  }
+
   private buildStructuredDirectives(input: {
     readonly chapterNumber: number;
     readonly language?: string;
@@ -722,6 +814,7 @@ export class PlannerAgent extends BaseAgent {
 
   private renderIntentMarkdown(
     intent: ChapterIntent,
+    brief: ChapterBrief | undefined,
     language: "zh" | "en",
     pendingHooks: string,
     chapterSummaries: string,
@@ -782,6 +875,26 @@ export class PlannerAgent extends BaseAgent {
       "## Outline Node",
       intent.outlineNode ?? "(not found)",
       "",
+      ...(brief ? [
+        "## Chapter Brief",
+        `- chapterType: ${brief.chapterType}`,
+        `- isGoldenOpening: ${brief.isGoldenOpening ? "true" : "false"}`,
+        `- dormantReason: ${brief.dormantReason ?? "(none)"}`,
+        "",
+        "### Beat Outline",
+        ...brief.beatOutline.map((beat) => `- ${beat.phase}: ${beat.instruction}`),
+        "",
+        "### Hook Plan",
+        ...(brief.hookPlan.length > 0
+          ? brief.hookPlan.map((item) => `- ${item.hookId}: ${item.movement} -> ${item.targetEffect}`)
+          : ["- none"]),
+        "",
+        "### Props And Setting",
+        ...(brief.propsAndSetting.length > 0
+          ? brief.propsAndSetting.map((item) => `- ${item}`)
+          : ["- none"]),
+        "",
+      ] : []),
       "## Must Keep",
       mustKeep,
       "",
@@ -809,6 +922,26 @@ export class PlannerAgent extends BaseAgent {
     ].join("\n");
   }
 
+  private buildSceneDirectiveFromBrief(brief: ChapterBrief): string {
+    const props = brief.propsAndSetting.length > 0
+      ? ` Use these on-page anchors: ${brief.propsAndSetting.join(", ")}.`
+      : "";
+    return `Run this as a ${brief.chapterType} chapter.${props}`.trim();
+  }
+
+  private buildArcDirectiveFromBrief(brief: ChapterBrief): string {
+    return brief.beatOutline
+      .map((beat) => `${beat.phase}: ${beat.instruction}`)
+      .join(" | ");
+  }
+
+  private mergeBriefStyleEmphasis(styleEmphasis: ReadonlyArray<string>, brief: ChapterBrief): string[] {
+    const derived = brief.isGoldenOpening
+      ? ["Honor golden-opening pacing without leaking chapter-planning meta language."]
+      : [];
+    return this.unique([...styleEmphasis, ...derived]);
+  }
+
   private unique(values: ReadonlyArray<string>): string[] {
     return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
   }
@@ -897,3 +1030,55 @@ export class PlannerAgent extends BaseAgent {
     }
   }
 }
+
+function extractBalancedJsonObject(text: string): string | null {
+  const start = text.indexOf("{");
+  if (start < 0) {
+    return null;
+  }
+
+  let depth = 0;
+  let inString = false;
+  let escaped = false;
+
+  for (let index = start; index < text.length; index += 1) {
+    const char = text[index]!;
+
+    if (inString) {
+      if (escaped) {
+        escaped = false;
+        continue;
+      }
+      if (char === "\\") {
+        escaped = true;
+        continue;
+      }
+      if (char === "\"") {
+        inString = false;
+      }
+      continue;
+    }
+
+    if (char === "\"") {
+      inString = true;
+      continue;
+    }
+
+    if (char === "{") {
+      depth += 1;
+      continue;
+    }
+
+    if (char === "}") {
+      depth -= 1;
+      if (depth === 0) {
+        return text.slice(start, index + 1);
+      }
+      if (depth < 0) {
+        return null;
+      }
+    }
+  }
+
+  return null;
+}

+ 22 - 1
packages/core/src/agents/writer.ts

@@ -15,7 +15,7 @@ import {
   type PostWriteViolation,
 } from "./post-write-validator.js";
 import { analyzeAITells } from "./ai-tells.js";
-import type { ChapterTrace, ContextPackage, RuleStack } from "../models/input-governance.js";
+import type { ChapterBrief, ChapterTrace, ContextPackage, RuleStack } from "../models/input-governance.js";
 import type { LengthSpec } from "../models/length-governance.js";
 import type { RuntimeStateDelta } from "../models/runtime-state.js";
 import { buildLengthSpec, countChapterLength } from "../utils/length-metrics.js";
@@ -48,6 +48,7 @@ export interface WriteChapterInput {
   readonly chapterNumber: number;
   readonly externalContext?: string;
   readonly chapterIntent?: string;
+  readonly chapterBrief?: ChapterBrief;
   readonly contextPackage?: ContextPackage;
   readonly ruleStack?: RuleStack;
   readonly trace?: ChapterTrace;
@@ -194,6 +195,7 @@ export class WriterAgent extends BaseAgent {
       ? this.buildGovernedUserPrompt({
           chapterNumber,
           chapterIntent: input.chapterIntent,
+          chapterBrief: input.chapterBrief,
           contextPackage: input.contextPackage,
           ruleStack: input.ruleStack,
           trace: input.trace,
@@ -811,6 +813,7 @@ ${lengthRequirementBlock}
   private buildGovernedUserPrompt(params: {
     readonly chapterNumber: number;
     readonly chapterIntent: string;
+    readonly chapterBrief?: ChapterBrief;
     readonly contextPackage: ContextPackage;
     readonly ruleStack: RuleStack;
     readonly trace?: ChapterTrace;
@@ -846,6 +849,20 @@ ${lengthRequirementBlock}
       ? `\n${sanitizeNarrativeEvidenceBlock(params.selectedEvidenceBlock, language)}\n`
       : "";
     const narrativeIntent = buildNarrativeIntentBrief(params.chapterIntent, language);
+    const briefBlock = params.chapterBrief
+      ? [
+          language === "en" ? "## Chapter Brief" : "## 章节简报",
+          `- chapterType: ${params.chapterBrief.chapterType}`,
+          `- isGoldenOpening: ${params.chapterBrief.isGoldenOpening ? "true" : "false"}`,
+          ...(params.chapterBrief.beatOutline.length > 0
+            ? params.chapterBrief.beatOutline.map((beat) => `- ${beat.phase}: ${beat.instruction}`)
+            : []),
+          ...(params.chapterBrief.propsAndSetting.length > 0
+            ? [`- propsAndSetting: ${params.chapterBrief.propsAndSetting.join(", ")}`]
+            : []),
+          "",
+        ].join("\n")
+      : "";
 
     if (params.language === "en") {
       return `Write chapter ${params.chapterNumber}.
@@ -853,6 +870,8 @@ ${lengthRequirementBlock}
 ## Chapter Intent
 ${narrativeIntent || "(none)"}
 
+${briefBlock}
+
 ## Selected Context
 ${contextSections || "(none)"}
 ${selectedEvidenceBlock}
@@ -879,6 +898,8 @@ ${lengthRequirementBlock}
 ## 本章意图
 ${narrativeIntent || "(无)"}
 
+${briefBlock}
+
 ## 已选上下文
 ${contextSections || "(无)"}
 ${selectedEvidenceBlock}

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

@@ -75,7 +75,12 @@ export {
   ChapterTraceSchema,
 } 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";
+export {
+  ComposerAgent,
+  composeGovernedChapter,
+  type ComposeChapterInput,
+  type ComposeChapterOutput,
+} from "./agents/composer.js";
 export {
   buildPlannerSystemPrompt,
   buildPlannerUserPrompt,

+ 85 - 1
packages/core/src/pipeline/persisted-governed-plan.ts

@@ -1,7 +1,7 @@
 import { readFile } from "node:fs/promises";
 import { join, relative } from "node:path";
 import type { PlanChapterOutput } from "../agents/planner.js";
-import { ChapterIntentSchema } from "../models/input-governance.js";
+import { ChapterBriefSchema, ChapterIntentSchema } from "../models/input-governance.js";
 
 export async function loadPersistedPlan(
   bookDir: string,
@@ -46,6 +46,7 @@ export async function loadPersistedPlan(
         styleEmphasis: readIntentList(sections, "Style Emphasis"),
         conflicts,
       }),
+      brief: parsePersistedChapterBrief(sections, chapterNumber),
       intentMarkdown,
       plannerInputs: [runtimePath],
       runtimePath,
@@ -90,6 +91,89 @@ function readIntentList(sections: Map<string, string[]>, name: string): string[]
     .map((line) => line.replace(/^-\s*/, ""));
 }
 
+function parsePersistedChapterBrief(
+  sections: Map<string, string[]>,
+  chapterNumber: number,
+) {
+  const lines = sections.get("Chapter Brief") ?? [];
+  if (lines.length === 0) {
+    return undefined;
+  }
+
+  const scalarMap = new Map<string, string>();
+  const beatOutline: Array<{ phase: "opening" | "development" | "reversal" | "payoff" | "hook"; instruction: string }> = [];
+  const hookPlan: Array<{ hookId: string; movement: "quiet-hold" | "refresh" | "advance" | "partial-payoff" | "full-payoff"; targetEffect: string }> = [];
+  const propsAndSetting: string[] = [];
+
+  let currentSubsection: string | null = null;
+  for (const rawLine of lines) {
+    const line = rawLine.trim();
+    if (!line) continue;
+
+    if (line.startsWith("### ")) {
+      currentSubsection = line.slice(4).trim();
+      continue;
+    }
+
+    if (currentSubsection === null && line.startsWith("- ")) {
+      const separator = line.indexOf(":");
+      if (separator > 2) {
+        scalarMap.set(line.slice(2, separator).trim(), line.slice(separator + 1).trim());
+      }
+      continue;
+    }
+
+    if (currentSubsection === "Beat Outline" && line.startsWith("- ")) {
+      const separator = line.indexOf(":");
+      if (separator > 2) {
+        const phase = line.slice(2, separator).trim();
+        const instruction = line.slice(separator + 1).trim();
+        if (phase && instruction) {
+          beatOutline.push({
+            phase: phase as "opening" | "development" | "reversal" | "payoff" | "hook",
+            instruction,
+          });
+        }
+      }
+      continue;
+    }
+
+    if (currentSubsection === "Hook Plan" && line.startsWith("- ") && line !== "- none") {
+      const [left, right] = line.slice(2).split("->").map((part) => part.trim());
+      if (left && right) {
+        const [hookId, movement] = left.split(":").map((part) => part.trim());
+        if (hookId && movement) {
+          hookPlan.push({
+            hookId,
+            movement: movement as "quiet-hold" | "refresh" | "advance" | "partial-payoff" | "full-payoff",
+            targetEffect: right,
+          });
+        }
+      }
+      continue;
+    }
+
+    if (currentSubsection === "Props And Setting" && line.startsWith("- ") && line !== "- none") {
+      propsAndSetting.push(line.replace(/^-\s*/, ""));
+    }
+  }
+
+  if (beatOutline.length === 0) {
+    return undefined;
+  }
+
+  return ChapterBriefSchema.parse({
+    chapter: chapterNumber,
+    goal: readIntentScalar(sections, "Goal") ?? scalarMap.get("goal"),
+    chapterType: scalarMap.get("chapterType") ?? "unspecified",
+    isGoldenOpening: scalarMap.get("isGoldenOpening") === "true",
+    beatOutline,
+    hookPlan,
+    propsAndSetting,
+    dormantReason: scalarMap.get("dormantReason"),
+  });
+}
+
 function isInvalidPersistedIntentScalar(value: string): boolean {
   const normalized = value.trim();
   if (!normalized) return true;

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

@@ -8,7 +8,7 @@ import type { GenreProfile } from "../models/genre-profile.js";
 import { ArchitectAgent, type ArchitectOutput } from "../agents/architect.js";
 import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js";
 import { PlannerAgent, type PlanChapterOutput } from "../agents/planner.js";
-import { ComposerAgent } from "../agents/composer.js";
+import { composeGovernedChapter, type ComposeChapterOutput } from "../agents/composer.js";
 import { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "../agents/writer.js";
 import { LengthNormalizerAgent } from "../agents/length-normalizer.js";
 import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
@@ -2066,7 +2066,7 @@ ${matrix}`,
     bookDir: string,
     chapterNumber: number,
     externalContext?: string,
-  ): Promise<Pick<WriteChapterInput, "externalContext" | "chapterIntent" | "contextPackage" | "ruleStack" | "trace">> {
+  ): Promise<Pick<WriteChapterInput, "externalContext" | "chapterIntent" | "chapterBrief" | "contextPackage" | "ruleStack" | "trace">> {
     if ((this.config.inputGovernanceMode ?? "v2") === "legacy") {
       return { externalContext };
     }
@@ -2081,6 +2081,7 @@ ${matrix}`,
 
     return {
       chapterIntent: plan.intentMarkdown,
+      chapterBrief: plan.brief,
       contextPackage: composed.contextPackage,
       ruleStack: composed.ruleStack,
       trace: composed.trace,
@@ -2721,12 +2722,10 @@ ${matrix}`,
     },
   ): Promise<{
     plan: PlanChapterOutput;
-    composed: Awaited<ReturnType<ComposerAgent["composeChapter"]>>;
+    composed: ComposeChapterOutput;
   }> {
     const plan = await this.resolveGovernedPlan(book, bookDir, chapterNumber, externalContext, options);
-
-    const composer = new ComposerAgent(this.agentCtxFor("composer", book.id));
-    const composed = await composer.composeChapter({
+    const composed = await composeGovernedChapter({
       book,
       bookDir,
       chapterNumber,

+ 52 - 0
packages/core/src/utils/context-assembly.ts

@@ -0,0 +1,52 @@
+import type {
+  ChapterTrace,
+  ContextPackage,
+  RuleStack,
+} from "../models/input-governance.js";
+import {
+  ChapterTraceSchema,
+  RuleStackSchema,
+} from "../models/input-governance.js";
+import type { PlanChapterOutput } from "../agents/planner.js";
+
+export function buildGovernedRuleStack(plan: PlanChapterOutput, chapterNumber: number): RuleStack {
+  return RuleStackSchema.parse({
+    layers: [
+      { id: "L1", name: "hard_facts", precedence: 100, scope: "global" },
+      { id: "L2", name: "author_intent", precedence: 80, scope: "book" },
+      { id: "L3", name: "planning", precedence: 60, scope: "arc" },
+      { id: "L4", name: "current_task", precedence: 70, scope: "local" },
+    ],
+    sections: {
+      hard: ["story_bible", "current_state", "book_rules"],
+      soft: ["author_intent", "current_focus", "volume_outline"],
+      diagnostic: ["anti_ai_checks", "continuity_audit", "style_regression_checks"],
+    },
+    overrideEdges: [
+      { from: "L4", to: "L3", allowed: true, scope: "current_chapter" },
+      { from: "L4", to: "L2", allowed: false, scope: "current_chapter" },
+      { from: "L4", to: "L1", allowed: false, scope: "current_chapter" },
+    ],
+    activeOverrides: plan.intent.conflicts.map((conflict) => ({
+      from: "L4",
+      to: "L3",
+      target: plan.intent.outlineNode ?? `chapter_${chapterNumber}`,
+      reason: conflict.resolution,
+    })),
+  });
+}
+
+export function buildGovernedTrace(params: {
+  readonly chapterNumber: number;
+  readonly plan: PlanChapterOutput;
+  readonly contextPackage: ContextPackage;
+  readonly composerInputs: ReadonlyArray<string>;
+}): ChapterTrace {
+  return ChapterTraceSchema.parse({
+    chapter: params.chapterNumber,
+    plannerInputs: params.plan.plannerInputs,
+    composerInputs: params.composerInputs,
+    selectedSources: params.contextPackage.selectedContext.map((entry) => entry.source),
+    notes: params.plan.intent.conflicts.map((conflict) => conflict.resolution),
+  });
+}

+ 41 - 0
packages/core/src/utils/runtime-writer.ts

@@ -0,0 +1,41 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import yaml from "js-yaml";
+import type {
+  ChapterTrace,
+  ContextPackage,
+  RuleStack,
+} from "../models/input-governance.js";
+
+export interface RuntimeArtifactWriteResult {
+  readonly contextPath: string;
+  readonly ruleStackPath: string;
+  readonly tracePath: string;
+}
+
+export async function writeGovernedRuntimeArtifacts(params: {
+  readonly runtimeDir: string;
+  readonly chapterNumber: number;
+  readonly contextPackage: ContextPackage;
+  readonly ruleStack: RuleStack;
+  readonly trace: ChapterTrace;
+}): Promise<RuntimeArtifactWriteResult> {
+  await mkdir(params.runtimeDir, { recursive: true });
+
+  const chapterSlug = `chapter-${String(params.chapterNumber).padStart(4, "0")}`;
+  const contextPath = join(params.runtimeDir, `${chapterSlug}.context.json`);
+  const ruleStackPath = join(params.runtimeDir, `${chapterSlug}.rule-stack.yaml`);
+  const tracePath = join(params.runtimeDir, `${chapterSlug}.trace.json`);
+
+  await Promise.all([
+    writeFile(contextPath, JSON.stringify(params.contextPackage, null, 2), "utf-8"),
+    writeFile(ruleStackPath, yaml.dump(params.ruleStack, { lineWidth: 120 }), "utf-8"),
+    writeFile(tracePath, JSON.stringify(params.trace, null, 2), "utf-8"),
+  ]);
+
+  return {
+    contextPath,
+    ruleStackPath,
+    tracePath,
+  };
+}