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

feat(planner): lay groundwork for chapter brief planning

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

+ 229 - 0
docs/plans/2026-04-11-planner-llm-chapter-brief-refactor.md

@@ -0,0 +1,229 @@
+# Planner LLM Chapter Brief Refactor Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Replace the current `goal + mustKeep` planning contract with an LLM-ready chapter-brief contract, dissolve `ComposerAgent` into pure utilities over time, and preserve a rule-based fallback while the new planner stabilizes.
+
+**Architecture:** Keep the current writing pipeline running by introducing the new structures in parallel first. The refactor proceeds in three layers: new chapter-brief models and planning-material retrieval, new LLM planner prompt/output path with fallback to the legacy planner, then composer de-agentization and writer/runtime consumption of the brief/context package.
+
+**Tech Stack:** TypeScript, Vitest, Zod, existing InkOS `BaseAgent`/pipeline/state utilities.
+
+---
+
+### Task 1: Introduce chapter-brief models in parallel with legacy intent
+
+**Files:**
+- Modify: `packages/core/src/models/input-governance.ts`
+- Modify: `packages/core/src/__tests__/models.test.ts`
+
+**Step 1: Write the failing tests**
+
+- Add schema tests for a new `ChapterBriefSchema` covering:
+  - `goal`
+  - `chapterType`
+  - `isGoldenOpening`
+  - `beatOutline`
+  - `hookPlan`
+  - `propsAndSetting`
+  - `dormantReason`
+- Add validation tests proving:
+  - brief accepts structured fields without `mustKeep`
+  - hook plan only requires active hook moves, not full dormant lists
+
+**Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/models.test.ts -t "ChapterBriefSchema"
+```
+
+Expected: FAIL because `ChapterBriefSchema` does not exist yet.
+
+**Step 3: Write minimal implementation**
+
+- Add new Zod schemas and exported types beside the existing legacy intent schemas.
+- Do not remove `ChapterIntentSchema` yet.
+
+**Step 4: Run test to verify it passes**
+
+Run the same command and expect PASS.
+
+### Task 2: Extract deterministic planning-material retrieval
+
+**Files:**
+- Create: `packages/core/src/utils/planning-materials.ts`
+- Create/Modify: `packages/core/src/__tests__/planning-materials.test.ts`
+- Modify: `packages/core/src/agents/planner.ts`
+
+**Step 1: Write the failing tests**
+
+- Add a test for `gatherPlanningMaterials(...)` that verifies it returns:
+  - current focus / author intent
+  - matched outline slice
+  - chapter summaries / chronicle
+  - active hooks
+  - previous ending hook
+- Add a test proving it does **not** require prior-chapter full正文 to build the planning packet.
+
+**Step 2: Run test to verify it fails**
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/planning-materials.test.ts
+```
+
+Expected: FAIL because the utility does not exist.
+
+**Step 3: Write minimal implementation**
+
+- Move planner-side file loading and memory retrieval into `gatherPlanningMaterials(...)`.
+- Keep it deterministic and pure from the planner’s point of view.
+
+**Step 4: Run test to verify it passes**
+
+Run the same command and expect PASS.
+
+### Task 3: Add planner prompt builder and LLM output parser
+
+**Files:**
+- Create: `packages/core/src/agents/planner-prompts.ts`
+- Modify: `packages/core/src/agents/planner.ts`
+- Create/Modify: `packages/core/src/__tests__/planner-prompts.test.ts`
+- Modify: `packages/core/src/__tests__/planner.test.ts`
+
+**Step 1: Write the failing tests**
+
+- Add prompt tests proving the planner prompt uses:
+  - chronicle/summaries
+  - previous ending hook
+  - active hooks to move
+  - no raw `mustKeep`
+- Add planner tests proving:
+  - the new planner path can parse structured brief JSON
+  - failed LLM planning falls back to the legacy rule-based intent path
+
+**Step 2: Run tests to verify they fail**
+
+```bash
+pnpm --dir packages/core exec vitest run \
+  src/__tests__/planner-prompts.test.ts \
+  src/__tests__/planner.test.ts
+```
+
+Expected: FAIL because the prompt builder and parser do not exist.
+
+**Step 3: Write minimal implementation**
+
+- Build a prompt builder that asks for `ChapterBrief` only.
+- In `PlannerAgent`, add:
+  - `planChapterLegacy(...)`
+  - `planChapterWithLLM(...)`
+  - fallback to legacy when the LLM path throws / returns invalid output
+- Keep returning the old `PlanChapterOutput` shape for now, but attach the new brief in parallel.
+
+**Step 4: Run tests to verify it passes**
+
+Run the same command and expect PASS.
+
+### Task 4: De-agentize composer into pure utilities without breaking runner
+
+**Files:**
+- Create: `packages/core/src/utils/context-assembly.ts`
+- Create: `packages/core/src/utils/runtime-writer.ts`
+- Modify: `packages/core/src/agents/composer.ts`
+- Modify: `packages/core/src/__tests__/composer.test.ts`
+
+**Step 1: Write the failing tests**
+
+- Add/adjust tests so they target:
+  - selected-context assembly
+  - runtime artifact writing
+  - trace generation
+- Keep coverage equivalent to the current `ComposerAgent` expectations.
+
+**Step 2: Run tests to verify it fails**
+
+```bash
+pnpm --dir packages/core exec vitest run src/__tests__/composer.test.ts
+```
+
+Expected: FAIL because the new utility boundaries do not exist.
+
+**Step 3: Write minimal implementation**
+
+- Move retrieval/assembly logic from `ComposerAgent` into utility functions.
+- Keep `ComposerAgent` as a thin shim initially so pipeline call sites remain stable.
+
+**Step 4: Run test to verify it passes**
+
+Run the same command and expect PASS.
+
+### Task 5: Switch writer/runtime consumption from intent-heavy contract to brief-driven contract
+
+**Files:**
+- Modify: `packages/core/src/agents/writer.ts`
+- Modify: `packages/core/src/utils/governed-context.ts`
+- Modify: `packages/core/src/pipeline/runner.ts`
+- Modify: `packages/core/src/pipeline/persisted-governed-plan.ts`
+- Modify: `packages/core/src/__tests__/writer.test.ts`
+- Modify: `packages/core/src/__tests__/pipeline-runner.test.ts`
+- Modify: `packages/core/src/__tests__/persisted-governed-plan.test.ts`
+
+**Step 1: Write the failing tests**
+
+- Add tests proving writer now consumes:
+  - brief goal
+  - beat outline
+  - hook plan
+  - props and setting
+- Add persisted plan tests that handle both legacy and new brief formats during the transition.
+
+**Step 2: Run tests to verify they fail**
+
+```bash
+pnpm --dir packages/core exec vitest run \
+  src/__tests__/writer.test.ts \
+  src/__tests__/pipeline-runner.test.ts \
+  src/__tests__/persisted-governed-plan.test.ts
+```
+
+Expected: FAIL because the writer/runtime still depend on `mustKeep`-heavy plan data.
+
+**Step 3: Write minimal implementation**
+
+- Use the new brief as the primary writer-facing contract.
+- Keep compatibility adapters for persisted legacy plans during the transition.
+
+**Step 4: Run tests to verify it passes**
+
+Run the same command and expect PASS.
+
+### Task 6: Final cleanup and verification
+
+**Files:**
+- Modify: `packages/core/src/index.ts`
+- Optional cleanup: `packages/core/src/agents/composer.ts` (thin wrapper or delete if safe)
+
+**Step 1: Verify focused suite**
+
+```bash
+pnpm --dir packages/core exec vitest run \
+  src/__tests__/models.test.ts \
+  src/__tests__/planning-materials.test.ts \
+  src/__tests__/planner-prompts.test.ts \
+  src/__tests__/planner.test.ts \
+  src/__tests__/composer.test.ts \
+  src/__tests__/writer.test.ts \
+  src/__tests__/pipeline-runner.test.ts \
+  src/__tests__/persisted-governed-plan.test.ts
+pnpm --dir packages/core run build
+```
+
+Expected: PASS.
+
+**Step 2: Commit**
+
+```bash
+git add packages/core/src docs/plans/2026-04-11-planner-llm-chapter-brief-refactor.md
+git commit -m "refactor(planner): introduce chapter brief planning flow"
+```

+ 53 - 0
packages/core/src/__tests__/models.test.ts

@@ -13,6 +13,7 @@ import {
   InputGovernanceModeSchema,
 } from "../models/project.js";
 import {
+  ChapterBriefSchema,
   ChapterIntentSchema,
   ContextPackageSchema,
   RuleStackSchema,
@@ -591,6 +592,58 @@ describe("ChapterIntentSchema", () => {
   });
 });
 
+describe("ChapterBriefSchema", () => {
+  it("accepts a structured chapter brief without mustKeep", () => {
+    const result = ChapterBriefSchema.parse({
+      chapter: 12,
+      goal: "Force the heroine to choose between the ledger and the mentor debt.",
+      chapterType: "confrontation",
+      isGoldenOpening: true,
+      beatOutline: [
+        { phase: "opening", instruction: "Open inside the warehouse argument, not with recap." },
+        { phase: "development", instruction: "Expose the ledger discrepancy through action, not explanation." },
+        { phase: "hook", instruction: "End on the unsigned transfer sheet changing hands." },
+      ],
+      hookPlan: [
+        {
+          hookId: "H019",
+          movement: "advance",
+          targetEffect: "Move the mentor-debt line from abstract guilt to immediate cost.",
+        },
+      ],
+      dormantReason: "Hold the black-ring line for the next pressure spike.",
+      propsAndSetting: ["warehouse transfer sheet", "wet loading dock", "A Sheng"],
+    });
+
+    expect(result.chapterType).toBe("confrontation");
+    expect(result.isGoldenOpening).toBe(true);
+    expect(result.beatOutline).toHaveLength(3);
+    expect(result.hookPlan).toEqual([
+      expect.objectContaining({
+        hookId: "H019",
+        movement: "advance",
+      }),
+    ]);
+    expect(result.propsAndSetting).toContain("wet loading dock");
+  });
+
+  it("defaults optional brief arrays to empty", () => {
+    const result = ChapterBriefSchema.parse({
+      chapter: 3,
+      goal: "Lock the protagonist into the first irreversible choice.",
+      chapterType: "opening",
+      beatOutline: [
+        { phase: "opening", instruction: "Open on the conflict." },
+      ],
+    });
+
+    expect(result.isGoldenOpening).toBe(false);
+    expect(result.hookPlan).toEqual([]);
+    expect(result.propsAndSetting).toEqual([]);
+    expect(result.dormantReason).toBeUndefined();
+  });
+});
+
 describe("ContextPackageSchema", () => {
   it("accepts selected context with provenance", () => {
     const result = ContextPackageSchema.parse({

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

@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import {
+  buildPlannerSystemPrompt,
+  buildPlannerUserPrompt,
+} from "../agents/planner-prompts.js";
+import type { PlanningMaterials } from "../utils/planning-materials.js";
+
+const MATERIALS: PlanningMaterials = {
+  storyDir: "/tmp/project/books/harbor/story",
+  authorIntent: "# Author Intent\n\nKeep the mentor debt line central.\n",
+  currentFocus: "# Current Focus\n\nReturn to the warehouse confrontation.\n",
+  storyBible: "# Story Bible\n\n- The jade seal cannot be destroyed.\n",
+  volumeOutline: "# Volume Outline\n\n## Chapter 4\nForce the warehouse confrontation.\n",
+  bookRulesRaw: "---\nprohibitions:\n  - Do not reveal the mastermind\n---",
+  currentState: "# Current State\n\n- Lin Yue is still injured.\n",
+  chapterSummariesRaw: "# Chapter Summaries",
+  outlineNode: "Force the warehouse confrontation.",
+  recentSummaries: [
+    {
+      chapter: 3,
+      title: "Canal Shadow",
+      characters: "Lin Yue,A Sheng",
+      events: "Ledger route points to the warehouse.",
+      stateChanges: "Harbor pressure rises.",
+      hookActivity: "The unsigned transfer sheet becomes urgent.",
+      mood: "tight",
+      chapterType: "pursuit",
+    },
+  ],
+  activeHooks: [
+    {
+      hookId: "H019",
+      startChapter: 2,
+      type: "mentor-debt",
+      status: "active",
+      lastAdvancedChapter: 3,
+      expectedPayoff: "Debt becomes immediate cost",
+      payoffTiming: "mid-arc",
+      notes: "Pressure should move on-page soon.",
+    },
+  ],
+  previousEndingHook: "The unsigned transfer sheet becomes urgent.",
+  memorySelection: {
+    summaries: [],
+    hooks: [],
+    activeHooks: [],
+    facts: [],
+    volumeSummaries: [],
+  },
+  plannerInputs: [],
+};
+
+describe("planner prompt builders", () => {
+  it("forbids meta-language leakage in the system prompt", () => {
+    const prompt = buildPlannerSystemPrompt("zh");
+
+    expect(prompt).toContain("ChapterBrief");
+    expect(prompt).toContain("第一章");
+    expect(prompt).toContain("小爽点");
+  });
+
+  it("builds a chronicle-driven user prompt without mustKeep", () => {
+    const prompt = buildPlannerUserPrompt({
+      chapterNumber: 4,
+      targetChapters: 20,
+      genreName: "xuanhuan",
+      language: "zh",
+      materials: MATERIALS,
+    });
+
+    expect(prompt).toContain("## Previous Ending Hook");
+    expect(prompt).toContain("unsigned transfer sheet");
+    expect(prompt).toContain("## Recent Chronicle");
+    expect(prompt).toContain("## Active Hooks");
+    expect(prompt).not.toContain("mustKeep");
+    expect(prompt).not.toContain("本章必须");
+  });
+});

+ 78 - 0
packages/core/src/__tests__/planning-materials.test.ts

@@ -0,0 +1,78 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { gatherPlanningMaterials } from "../utils/planning-materials.js";
+
+describe("gatherPlanningMaterials", () => {
+  let root: string;
+  let bookDir: string;
+  let storyDir: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-planning-materials-"));
+    bookDir = join(root, "books", "harbor-book");
+    storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nStay on the mentor-debt line.\n", "utf-8"),
+      writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nReturn to the harbor ledger.\n", "utf-8"),
+      writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nForce the warehouse confrontation.\n", "utf-8"),
+      writeFile(join(storyDir, "book_rules.md"), "---\nprohibitions:\n  - Do not reveal the mastermind\n---\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue is still injured.\n", "utf-8"),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "# Chapter Summaries",
+          "",
+          "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+          "|---|---|---|---|---|---|---|---|",
+          "| 2 | Trial Fallout | Lin Yue | Mentor leaves the trial | Debt deepens | Mentor debt unresolved | tense | investigation |",
+          "| 3 | Canal Shadow | Lin Yue,A Sheng | Ledger trail appears | Harbor pressure rises | Ledger route now points to the warehouse | tight | pursuit |",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "# Pending Hooks",
+          "",
+          "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes |",
+          "|---|---|---|---|---|---|---|---|",
+          "| H019 | 2 | mentor-debt | active | 3 | Mentor debt becomes immediate cost | mid-arc | Ledger pressure keeps tightening |",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("collects deterministic planning materials without reading prior chapter bodies", async () => {
+    const result = await gatherPlanningMaterials({
+      bookDir,
+      chapterNumber: 4,
+      goal: "Force the warehouse confrontation.",
+      outlineNode: "Force the warehouse confrontation.",
+    });
+
+    expect(result.authorIntent).toContain("mentor-debt");
+    expect(result.currentFocus).toContain("harbor ledger");
+    expect(result.recentSummaries).toHaveLength(2);
+    expect(result.previousEndingHook).toContain("warehouse");
+    expect(result.activeHooks).toEqual([
+      expect.objectContaining({
+        hookId: "H019",
+      }),
+    ]);
+    expect(result.plannerInputs).toEqual(expect.arrayContaining([
+      join(storyDir, "author_intent.md"),
+      join(storyDir, "chapter_summaries.md"),
+      join(storyDir, "pending_hooks.md"),
+    ]));
+  });
+});

+ 102 - 0
packages/core/src/agents/planner-prompts.ts

@@ -0,0 +1,102 @@
+import type { PlanningMaterials } from "../utils/planning-materials.js";
+
+export interface PlannerPromptInput {
+  readonly chapterNumber: number;
+  readonly targetChapters: number;
+  readonly genreName: string;
+  readonly language: "zh" | "en";
+  readonly materials: PlanningMaterials;
+}
+
+export function buildPlannerSystemPrompt(language: "zh" | "en"): string {
+  if (language === "en") {
+    return [
+      "You are InkOS planner.",
+      "Turn planning materials into a structured ChapterBrief JSON object.",
+      "Do not output prose, markdown, or explanatory notes.",
+      "Do not emit meta-language such as 'Chapter 1', 'this chapter should', 'mini payoff', or planning labels that could leak into fiction.",
+      "Focus on chapter-level beats, hook movement, and concrete props/setting needed for execution.",
+    ].join(" ");
+  }
+
+  return [
+    "你是 InkOS 的章节规划器。",
+    "你的任务是把规划材料编译成结构化 ChapterBrief JSON。",
+    "不要输出散文、Markdown、解释说明。",
+    "禁止输出会污染正文的元语言,例如“第一章”“这一章”“小爽点”“本章必须”等。",
+    "只输出章节级 beat、hook 处理和本章实际需要的地点/物件/人物。",
+  ].join(" ");
+}
+
+function renderRecentSummaries(materials: PlanningMaterials): string {
+  if (materials.recentSummaries.length === 0) {
+    return "(none)";
+  }
+
+  return materials.recentSummaries
+    .map((summary) => [
+      `- Ch${summary.chapter}: ${summary.title || "(untitled)"}`,
+      summary.events,
+      summary.stateChanges,
+      summary.hookActivity,
+      summary.chapterType,
+    ].filter(Boolean).join(" | "))
+    .join("\n");
+}
+
+function renderActiveHooks(materials: PlanningMaterials): string {
+  if (materials.activeHooks.length === 0) {
+    return "(none)";
+  }
+
+  return materials.activeHooks
+    .slice(0, 6)
+    .map((hook) => `- ${hook.hookId} | ${hook.type} | ${hook.expectedPayoff} | last=${hook.lastAdvancedChapter} | timing=${hook.payoffTiming ?? "unspecified"}`)
+    .join("\n");
+}
+
+export function buildPlannerUserPrompt(input: PlannerPromptInput): string {
+  const { materials } = input;
+
+  return [
+    `## Chapter`,
+    `${input.chapterNumber}/${input.targetChapters}`,
+    "",
+    "## Genre",
+    input.genreName,
+    "",
+    "## Goal Seed",
+    materials.outlineNode ?? "(no matched outline slice)",
+    "",
+    "## Author Intent",
+    materials.authorIntent,
+    "",
+    "## Current Focus",
+    materials.currentFocus,
+    "",
+    "## Current State",
+    materials.currentState,
+    "",
+    "## Story Bible",
+    materials.storyBible,
+    "",
+    "## Previous Ending Hook",
+    materials.previousEndingHook ?? "(none)",
+    "",
+    "## Recent Chronicle",
+    renderRecentSummaries(materials),
+    "",
+    "## Active Hooks",
+    renderActiveHooks(materials),
+    "",
+    "## Output Requirements",
+    [
+      "- Return valid JSON only.",
+      "- Choose one concrete chapterType.",
+      "- Produce 3-5 beats in beatOutline.",
+      "- Only list hooks you will actively move this chapter.",
+      "- Use dormantReason to explain why other lines stay still.",
+      "- propsAndSetting should only include items that must appear on-page.",
+    ].join("\n"),
+  ].join("\n");
+}

+ 31 - 46
packages/core/src/agents/planner.ts

@@ -8,10 +8,10 @@ import {
   parseChapterSummariesMarkdown,
   renderHookSnapshot,
   renderSummarySnapshot,
-  retrieveMemorySelection,
 } from "../utils/memory-retrieval.js";
 import { analyzeChapterCadence } from "../utils/chapter-cadence.js";
 import { buildPlannerHookAgenda } from "../utils/hook-agenda.js";
+import { gatherPlanningMaterials } from "../utils/planning-materials.js";
 
 export interface PlanChapterInput {
   readonly book: BookConfig;
@@ -37,50 +37,37 @@ export class PlannerAgent extends BaseAgent {
     const runtimeDir = join(storyDir, "runtime");
     await mkdir(runtimeDir, { recursive: true });
 
-    const sourcePaths = {
-      authorIntent: join(storyDir, "author_intent.md"),
-      currentFocus: join(storyDir, "current_focus.md"),
-      storyBible: join(storyDir, "story_bible.md"),
-      volumeOutline: join(storyDir, "volume_outline.md"),
-      chapterSummaries: join(storyDir, "chapter_summaries.md"),
-      bookRules: join(storyDir, "book_rules.md"),
-      currentState: join(storyDir, "current_state.md"),
-    } as const;
-
-    const [
-      authorIntent,
-      currentFocus,
-      storyBible,
-      volumeOutline,
-      chapterSummaries,
-      bookRulesRaw,
-      currentState,
-    ] = await Promise.all([
-      this.readFileOrDefault(sourcePaths.authorIntent),
-      this.readFileOrDefault(sourcePaths.currentFocus),
-      this.readFileOrDefault(sourcePaths.storyBible),
-      this.readFileOrDefault(sourcePaths.volumeOutline),
-      this.readFileOrDefault(sourcePaths.chapterSummaries),
-      this.readFileOrDefault(sourcePaths.bookRules),
-      this.readFileOrDefault(sourcePaths.currentState),
-    ]);
+    const volumeOutline = await this.readFileOrDefault(join(storyDir, "volume_outline.md"));
 
     const outlineNode = this.findOutlineNode(volumeOutline, input.chapterNumber);
     const matchedOutlineAnchor = this.hasMatchedOutlineAnchor(volumeOutline, input.chapterNumber);
-    const goal = this.deriveGoal(input.externalContext, currentFocus, authorIntent, outlineNode, input.chapterNumber);
-    const parsedRules = parseBookRules(bookRulesRaw);
-    const mustKeep = this.collectMustKeep(currentState, storyBible);
-    const mustAvoid = this.collectMustAvoid(currentFocus, parsedRules.rules.prohibitions);
-    const styleEmphasis = this.collectStyleEmphasis(authorIntent, currentFocus);
-    const conflicts = this.collectConflicts(input.externalContext, currentFocus, outlineNode, volumeOutline);
+    const seedMaterials = await gatherPlanningMaterials({
+      bookDir: input.bookDir,
+      chapterNumber: input.chapterNumber,
+      goal: this.deriveGoal(input.externalContext, "", "", outlineNode, input.chapterNumber),
+      outlineNode,
+    });
+    const goal = this.deriveGoal(
+      input.externalContext,
+      seedMaterials.currentFocus,
+      seedMaterials.authorIntent,
+      outlineNode,
+      input.chapterNumber,
+    );
+    const parsedRules = parseBookRules(seedMaterials.bookRulesRaw);
+    const mustKeep = this.collectMustKeep(seedMaterials.currentState, seedMaterials.storyBible);
+    const mustAvoid = this.collectMustAvoid(seedMaterials.currentFocus, parsedRules.rules.prohibitions);
+    const styleEmphasis = this.collectStyleEmphasis(seedMaterials.authorIntent, seedMaterials.currentFocus);
+    const conflicts = this.collectConflicts(input.externalContext, seedMaterials.currentFocus, outlineNode, volumeOutline);
     const planningAnchor = conflicts.length > 0 ? undefined : outlineNode;
-    const memorySelection = await retrieveMemorySelection({
+    const materials = await gatherPlanningMaterials({
       bookDir: input.bookDir,
       chapterNumber: input.chapterNumber,
       goal,
       outlineNode: planningAnchor,
       mustKeep,
     });
+    const memorySelection = materials.memorySelection;
     const activeHookCount = memorySelection.activeHooks.filter(
       (hook) => hook.status !== "resolved" && hook.status !== "deferred",
     ).length;
@@ -91,13 +78,13 @@ export class PlannerAgent extends BaseAgent {
       language: input.book.language ?? "zh",
     });
     const directives = this.buildStructuredDirectives({
-      chapterNumber: input.chapterNumber,
-      language: input.book.language,
-      volumeOutline,
-      outlineNode,
-      matchedOutlineAnchor,
-      chapterSummaries,
-    });
+          chapterNumber: input.chapterNumber,
+          language: input.book.language,
+          volumeOutline,
+          outlineNode,
+          matchedOutlineAnchor,
+          chapterSummaries: materials.chapterSummariesRaw,
+        });
 
     // ── Skill injection: translate writing methodology into concrete rules ──
     this.injectStructuralSkills({
@@ -110,7 +97,7 @@ export class PlannerAgent extends BaseAgent {
       hookAgenda,
       cadence: analyzeChapterCadence({
         language: this.isChineseLanguage(input.book.language) ? "zh" : "en",
-        rows: parseChapterSummariesMarkdown(chapterSummaries)
+        rows: parseChapterSummariesMarkdown(materials.chapterSummariesRaw)
           .filter((s) => s.chapter < input.chapterNumber)
           .sort((a, b) => a.chapter - b.chapter)
           .slice(-4)
@@ -144,9 +131,7 @@ export class PlannerAgent extends BaseAgent {
       intent,
       intentMarkdown,
       plannerInputs: [
-        ...Object.values(sourcePaths),
-        join(storyDir, "pending_hooks.md"),
-        ...(memorySelection.dbPath ? [memorySelection.dbPath] : []),
+        ...materials.plannerInputs,
       ],
       runtimePath,
     };

+ 17 - 0
packages/core/src/index.ts

@@ -38,6 +38,10 @@ export {
 } from "./models/runtime-state.js";
 export {
   type ChapterConflict,
+  type ChapterBeatPhase,
+  type ChapterBeat,
+  type ChapterHookMove,
+  type ChapterBrief,
   type HookMovement,
   type HookPressureLevel,
   type HookPressure,
@@ -52,6 +56,10 @@ export {
   type RuleStack,
   type ChapterTrace,
   ChapterConflictSchema,
+  ChapterBeatPhaseSchema,
+  ChapterBeatSchema,
+  ChapterHookMoveSchema,
+  ChapterBriefSchema,
   HookMovementSchema,
   HookPressureLevelSchema,
   HookPressureSchema,
@@ -68,6 +76,15 @@ export {
 } 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 {
+  buildPlannerSystemPrompt,
+  buildPlannerUserPrompt,
+  type PlannerPromptInput,
+} from "./agents/planner-prompts.js";
+export {
+  gatherPlanningMaterials,
+  type PlanningMaterials,
+} from "./utils/planning-materials.js";
 
 // 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";

+ 35 - 0
packages/core/src/models/input-governance.ts

@@ -57,6 +57,41 @@ export const HookAgendaSchema = z.object({
 
 export type HookAgenda = z.infer<typeof HookAgendaSchema>;
 
+export const ChapterBeatPhaseSchema = z.enum([
+  "opening",
+  "development",
+  "reversal",
+  "payoff",
+  "hook",
+]);
+export type ChapterBeatPhase = z.infer<typeof ChapterBeatPhaseSchema>;
+
+export const ChapterBeatSchema = z.object({
+  phase: ChapterBeatPhaseSchema,
+  instruction: z.string().min(1),
+});
+export type ChapterBeat = z.infer<typeof ChapterBeatSchema>;
+
+export const ChapterHookMoveSchema = z.object({
+  hookId: z.string().min(1),
+  movement: HookMovementSchema,
+  targetEffect: z.string().min(1),
+});
+export type ChapterHookMove = z.infer<typeof ChapterHookMoveSchema>;
+
+export const ChapterBriefSchema = z.object({
+  chapter: z.number().int().min(1),
+  goal: z.string().min(1),
+  chapterType: z.string().min(1),
+  isGoldenOpening: z.boolean().default(false),
+  beatOutline: z.array(ChapterBeatSchema).min(1),
+  hookPlan: z.array(ChapterHookMoveSchema).default([]),
+  dormantReason: z.string().optional(),
+  propsAndSetting: z.array(z.string().min(1)).default([]),
+});
+
+export type ChapterBrief = z.infer<typeof ChapterBriefSchema>;
+
 export const ChapterIntentSchema = z.object({
   chapter: z.number().int().min(1),
   goal: z.string().min(1),

+ 105 - 0
packages/core/src/utils/planning-materials.ts

@@ -0,0 +1,105 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import type { StoredHook, StoredSummary } from "../state/memory-db.js";
+import {
+  parseChapterSummariesMarkdown,
+  retrieveMemorySelection,
+  type MemorySelection,
+} from "./memory-retrieval.js";
+
+export interface PlanningMaterials {
+  readonly storyDir: string;
+  readonly authorIntent: string;
+  readonly currentFocus: string;
+  readonly storyBible: string;
+  readonly volumeOutline: string;
+  readonly bookRulesRaw: string;
+  readonly currentState: string;
+  readonly chapterSummariesRaw: string;
+  readonly outlineNode?: string;
+  readonly recentSummaries: ReadonlyArray<StoredSummary>;
+  readonly activeHooks: ReadonlyArray<StoredHook>;
+  readonly previousEndingHook?: string;
+  readonly memorySelection: MemorySelection;
+  readonly plannerInputs: ReadonlyArray<string>;
+}
+
+async function readFileOrDefault(path: string): Promise<string> {
+  try {
+    return await readFile(path, "utf-8");
+  } catch {
+    return "(文件尚未创建)";
+  }
+}
+
+export async function gatherPlanningMaterials(params: {
+  readonly bookDir: string;
+  readonly chapterNumber: number;
+  readonly goal: string;
+  readonly outlineNode?: string;
+  readonly mustKeep?: ReadonlyArray<string>;
+}): Promise<PlanningMaterials> {
+  const storyDir = join(params.bookDir, "story");
+  const sourcePaths = {
+    authorIntent: join(storyDir, "author_intent.md"),
+    currentFocus: join(storyDir, "current_focus.md"),
+    storyBible: join(storyDir, "story_bible.md"),
+    volumeOutline: join(storyDir, "volume_outline.md"),
+    chapterSummaries: join(storyDir, "chapter_summaries.md"),
+    bookRules: join(storyDir, "book_rules.md"),
+    currentState: join(storyDir, "current_state.md"),
+  } as const;
+
+  const [
+    authorIntent,
+    currentFocus,
+    storyBible,
+    volumeOutline,
+    chapterSummariesRaw,
+    bookRulesRaw,
+    currentState,
+  ] = await Promise.all([
+    readFileOrDefault(sourcePaths.authorIntent),
+    readFileOrDefault(sourcePaths.currentFocus),
+    readFileOrDefault(sourcePaths.storyBible),
+    readFileOrDefault(sourcePaths.volumeOutline),
+    readFileOrDefault(sourcePaths.chapterSummaries),
+    readFileOrDefault(sourcePaths.bookRules),
+    readFileOrDefault(sourcePaths.currentState),
+  ]);
+
+  const chapterSummaries = parseChapterSummariesMarkdown(chapterSummariesRaw)
+    .filter((summary) => summary.chapter < params.chapterNumber)
+    .sort((left, right) => right.chapter - left.chapter);
+  const recentSummaries = chapterSummaries.slice(0, 4).sort((left, right) => left.chapter - right.chapter);
+  const previousEndingHook = chapterSummaries[0]?.hookActivity || undefined;
+
+  const memorySelection = await retrieveMemorySelection({
+    bookDir: params.bookDir,
+    chapterNumber: params.chapterNumber,
+    goal: params.goal,
+    outlineNode: params.outlineNode,
+    mustKeep: params.mustKeep,
+  });
+
+  return {
+    storyDir,
+    authorIntent,
+    currentFocus,
+    storyBible,
+    volumeOutline,
+    bookRulesRaw,
+    currentState,
+    chapterSummariesRaw,
+    outlineNode: params.outlineNode,
+    recentSummaries,
+    activeHooks: memorySelection.activeHooks,
+    previousEndingHook,
+    memorySelection,
+    plannerInputs: [
+      ...Object.values(sourcePaths),
+      join(storyDir, "pending_hooks.md"),
+      ...(memorySelection.dbPath ? [memorySelection.dbPath] : []),
+    ],
+  };
+}