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

refactor(v13): move book_rules structured fields to story_frame frontmatter [phase 5/8 cleanup 3]

Before this cleanup, book_rules.md had two jobs in one file:
1. YAML frontmatter schema container (protagonist, prohibitions, ...)
2. Narrative guidance prose (e.g. 叙事视角 / 核心冲突驱动)

Phase 5 had already migrated the prose content into story_frame.md's
"世界观底色" section, leaving book_rules.md as a half-deprecated hybrid.
This commit completes the narrowing by moving the YAML frontmatter —
the one remaining reason to read the file — onto story_frame.md's top.

Design choice: embed frontmatter on story_frame.md (the authoritative
setting file now) rather than renaming book_rules.md → book_rules.yaml.
One canonical location beats two siblings with overlapping purpose.

- architect.ts: writeFoundationFiles splits the frontmatter out of the
  LLM's book_rules section and prepends it to story_frame.md. The LLM
  prompt is unchanged — the split happens at persistence time.
- architect.ts: book_rules.md becomes a compat shim (matching the
  pattern already used for story_bible.md / character_matrix.md). It
  still contains the narrative-guidance body for any external reader
  that still opens it, behind a "兼容指针——已废弃" banner.
- rules-reader.ts: readBookRules() prefers outline/story_frame.md
  frontmatter, falls back to legacy story/book_rules.md for books
  initialized before this cleanup. The YAML parser already tolerates
  frontmatter found anywhere in the document, so no parser change.
- planner-context.ts: readBookRules() now renders the structured fields
  (protagonist / prohibitions / genreLock / fanficMode) as a compact
  markdown block sourced from the canonical ParsedBookRules, instead of
  returning the raw book_rules.md file. Body prose (narrative guidance)
  is still included verbatim so the planner prompt sees the same text
  shape as before.

All 561 core tests still pass. No LLM prompt changes, no new schema
fields, no rename — only the file a single field ends up living in
changed.
Ma 6 месяцев назад
Родитель
Сommit
c9e19326b5

+ 54 - 4
packages/core/src/agents/architect.ts

@@ -20,7 +20,8 @@ import { renderHookSnapshot } from "../utils/memory-retrieval.js";
 //   roles/次要角色/<name>.md    ← one file per minor character
 //   story_bible.md              ← compat shim (pointer + excerpt)
 //   character_matrix.md         ← compat shim (pointer + role listing)
-//   book_rules.md               ← YAML frontmatter kept for readBookRules()
+//   book_rules.md               ← compat shim (cleanup #3). YAML frontmatter
+//                                 now lives on story_frame.md.
 //   current_state.md            ← runtime state (unchanged)
 //   pending_hooks.md            ← runtime state (unchanged)
 //   emotional_arcs.md           ← runtime state (unchanged)
@@ -35,6 +36,25 @@ export interface ArchitectRole {
   readonly content: string;
 }
 
+/**
+ * Split a markdown string into its leading YAML frontmatter block and the
+ * remaining body. Returns `frontmatter: null` when no frontmatter is present.
+ * Only recognises a frontmatter block that starts on the FIRST non-empty
+ * line — embedded `---` sections in prose are left alone.
+ */
+function extractYamlFrontmatter(raw: string): { frontmatter: string | null; body: string } {
+  if (!raw) return { frontmatter: null, body: "" };
+  const stripped = raw.replace(/^```(?:md|markdown|yaml)?\s*\n/, "").replace(/\n```\s*$/, "");
+  const leadingMatch = stripped.match(/^\s*---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
+  if (!leadingMatch) {
+    return { frontmatter: null, body: stripped };
+  }
+  return {
+    frontmatter: `---\n${leadingMatch[1]}\n---`,
+    body: leadingMatch[2].trim(),
+  };
+}
+
 export interface ArchitectOutput {
   // Legacy shape — kept for back-compat with consumers that still read the
   // old file names. Filled from the new prose sections below when Phase 5
@@ -618,6 +638,20 @@ Rules:
     return `# 角色矩阵(兼容指针——已废弃)\n\n> 本文件仅为外部读取保留。权威来源已迁移至 roles/ 文件夹(一人一卡)。\n\n## 主要角色\n\n${majorLines.join("\n") || "(无)"}\n\n## 次要角色\n\n${minorLines.join("\n") || "(无)"}\n`;
   }
 
+  private buildBookRulesShim(bookRulesBody: string, language: "zh" | "en"): string {
+    const trimmedBody = bookRulesBody.trim();
+    if (language === "en") {
+      const excerpt = trimmedBody
+        ? `\n\n## Narrative guidance excerpt\n\n${trimmedBody}\n`
+        : "";
+      return `# Book Rules (compat pointer — deprecated)\n\n> This file is kept for external readers only. The authoritative YAML frontmatter (protagonist / prohibitions / genreLock / ...) now lives at the top of outline/story_frame.md. readBookRules() prefers that location and only falls back here for books initialized before Phase 5 cleanup #3.${excerpt}`;
+    }
+    const excerpt = trimmedBody
+      ? `\n\n## 叙事指引摘录\n\n${trimmedBody}\n`
+      : "";
+    return `# 本书规则(兼容指针——已废弃)\n\n> 本文件仅为外部读取保留。权威 YAML frontmatter(protagonist / prohibitions / genreLock / ...)已迁移至 outline/story_frame.md 顶部。readBookRules() 优先读那里,只有 Phase 5 cleanup #3 之前的老书才会回退到本文件。${excerpt}`;
+  }
+
   // -------------------------------------------------------------------------
   // File writing
   // -------------------------------------------------------------------------
@@ -642,11 +676,21 @@ Rules:
 
     const writes: Array<Promise<void>> = [];
 
-    const storyFrame = output.storyFrame ?? output.storyBible;
+    const storyFrameBody = output.storyFrame ?? output.storyBible;
     const volumeMap = output.volumeMap ?? output.volumeOutline;
     const rhythmPrinciples = output.rhythmPrinciples ?? "";
     const roles = output.roles ?? [];
 
+    // Cleanup #3: book_rules YAML frontmatter is now the authoritative
+    // schema for structured fields (protagonist, prohibitions, …). We prepend
+    // it to story_frame.md so readers have one canonical place to look.
+    // book_rules.md becomes a compat shim.
+    const { frontmatter: bookRulesFrontmatter, body: bookRulesBody } =
+      extractYamlFrontmatter(output.bookRules);
+    const storyFrame = bookRulesFrontmatter
+      ? `${bookRulesFrontmatter}\n\n${storyFrameBody.trim()}\n`
+      : storyFrameBody;
+
     // Phase 5 primary prose files
     writes.push(writeFile(join(outlineDir, "story_frame.md"), storyFrame, "utf-8"));
     writes.push(writeFile(join(outlineDir, "volume_map.md"), volumeMap, "utf-8"));
@@ -678,8 +722,14 @@ Rules:
     // outline/volume_map.md and falls back to legacy volume_outline.md for
     // books initialized before Phase 5.
 
-    // book_rules.md is still produced — readBookRules() depends on YAML.
-    writes.push(writeFile(join(storyDir, "book_rules.md"), output.bookRules, "utf-8"));
+    // book_rules.md is now a compat shim — the authoritative YAML
+    // frontmatter lives on story_frame.md (cleanup #3). readBookRules()
+    // prefers story_frame.md but still falls back here for older books.
+    writes.push(writeFile(
+      join(storyDir, "book_rules.md"),
+      this.buildBookRulesShim(bookRulesBody, language),
+      "utf-8",
+    ));
 
     // Runtime state files (untouched by Phase 5)
     writes.push(writeFile(join(storyDir, "current_state.md"), output.currentState, "utf-8"));

+ 48 - 1
packages/core/src/agents/planner-context.ts

@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
 import { dirname, join } from "node:path";
 import { parseMarkdownTableRows } from "../utils/story-markdown.js";
 import { readCharacterContext } from "../utils/outline-paths.js";
+import { readBookRules as readStructuredBookRules } from "./rules-reader.js";
 
 async function readOrEmpty(path: string): Promise<string> {
   try {
@@ -33,8 +34,54 @@ export async function readPendingHooks(storyDir: string): Promise<string> {
   return readOrEmpty(join(storyDir, "pending_hooks.md"));
 }
 
+/**
+ * Render the structured book rules (protagonist / prohibitions / genreLock /
+ * behavioral constraints) as a compact markdown block for the planner prompt.
+ *
+ * Phase 5 cleanup #3: reads the YAML frontmatter via readStructuredBookRules
+ * (which prefers story_frame.md and falls back to legacy book_rules.md).
+ * Returns "" when no structured rules are defined — the planner template
+ * provides its own placeholder for that case.
+ */
 export async function readBookRules(storyDir: string): Promise<string> {
-  return readOrEmpty(join(storyDir, "book_rules.md"));
+  const bookDir = dirname(storyDir);
+  const parsed = await readStructuredBookRules(bookDir);
+  if (!parsed) return "";
+
+  const { rules, body } = parsed;
+  const lines: string[] = [];
+
+  if (rules.protagonist) {
+    const proto = rules.protagonist;
+    const personality = proto.personalityLock.join("、");
+    const constraints = proto.behavioralConstraints.join("、");
+    lines.push(`- 主角 ${proto.name}${personality ? ` / 人设锁:${personality}` : ""}${constraints ? ` / 行为约束:${constraints}` : ""}`);
+  }
+
+  if (rules.prohibitions.length > 0) {
+    lines.push("- 本书禁忌:");
+    for (const p of rules.prohibitions) {
+      lines.push(`  - ${p}`);
+    }
+  }
+
+  if (rules.genreLock) {
+    const forbidden = rules.genreLock.forbidden.join("、");
+    lines.push(`- 题材锁:${rules.genreLock.primary}${forbidden ? ` / 禁止混入:${forbidden}` : ""}`);
+  }
+
+  if (rules.fanficMode) {
+    lines.push(`- 同人模式:${rules.fanficMode}`);
+  }
+
+  const trimmedBody = body.trim();
+  // The body holds narrative guidance prose (e.g. 叙事视角). Include it verbatim
+  // so the planner sees the same text as before the cleanup.
+  if (trimmedBody) {
+    lines.push("", trimmedBody);
+  }
+
+  return lines.join("\n").trim();
 }
 
 /**

+ 19 - 5
packages/core/src/agents/rules-reader.ts

@@ -86,13 +86,27 @@ export function getBuiltinGenresDir(): string {
 }
 
 /**
- * Load book_rules.md from the book's story directory.
- * Returns null if the file doesn't exist.
+ * Load the structured book rules (YAML frontmatter).
+ *
+ * Phase 5 cleanup #3: the YAML frontmatter now lives at the top of
+ * outline/story_frame.md. For books initialized before that cleanup it may
+ * still live in book_rules.md instead, so we fall back to that legacy path
+ * when story_frame.md has no frontmatter (or no file at all).
+ *
+ * Returns null only if NEITHER source yields parseable rules.
  */
 export async function readBookRules(bookDir: string): Promise<ParsedBookRules | null> {
-  const raw = await tryReadFile(join(bookDir, "story/book_rules.md"));
-  if (!raw) return null;
-  return parseBookRules(raw);
+  const storyFrameRaw = await tryReadFile(join(bookDir, "story/outline/story_frame.md"));
+  if (storyFrameRaw && /^\s*---\s*\n/.test(storyFrameRaw)) {
+    const parsed = parseBookRules(storyFrameRaw);
+    // parseBookRules returns defaults when no YAML matches — only treat the
+    // story_frame hit as authoritative when the leading frontmatter exists.
+    if (parsed) return parsed;
+  }
+
+  const legacyRaw = await tryReadFile(join(bookDir, "story/book_rules.md"));
+  if (!legacyRaw) return null;
+  return parseBookRules(legacyRaw);
 }
 
 export async function readBookLanguage(bookDir: string): Promise<"zh" | "en" | undefined> {