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

fix(v13): reject compat-shim book_rules as valid rule source [phase hotfix 1]

When story_frame.md frontmatter is broken (or missing), readBookRules() falls
back to legacy book_rules.md. For new-layout books that file is the architect-
emitted compat shim with no YAML — parseBookRules previously parsed it as
"default empty rules", silently zeroing out protagonist / prohibitions /
genreLock for every consumer downstream.

Fix:
- parseBookRules() now returns null when the input has no parseable
  frontmatter AND matches a Phase 5 shim marker (heading + pointer line).
  Legitimate legacy book_rules.md files (with prose body) still parse to
  default-rules + body as before.
- isBookRulesShim() encodes the marker check (matches buildBookRulesShim in
  architect.ts).
- rules-reader.readBookRules() logs a warning when the legacy fallback is
  itself a shim and propagates null instead of pretending rules exist.
- planner.planChapter() handles the now-nullable parseBookRules result.

The "broken frontmatter + shim-only book_rules" path is the common new-book
failure mode; it MUST surface, not silently corrupt downstream rule
enforcement.
Ma 6 месяцев назад
Родитель
Сommit
c29f0659d5

+ 2 - 1
packages/core/src/agents/planner.ts

@@ -89,8 +89,9 @@ export class PlannerAgent extends BaseAgent {
       input.chapterNumber,
     );
     const parsedRules = parseBookRules(seedMaterials.bookRulesRaw);
+    const prohibitions = parsedRules?.rules.prohibitions ?? [];
     const mustKeep = this.collectMustKeep(seedMaterials.currentState, seedMaterials.storyBible);
-    const mustAvoid = this.collectMustAvoid(seedMaterials.currentFocus, parsedRules.rules.prohibitions);
+    const mustAvoid = this.collectMustAvoid(seedMaterials.currentFocus, prohibitions);
     const styleEmphasis = this.collectStyleEmphasis(seedMaterials.authorIntent, seedMaterials.currentFocus);
     const materials = await gatherPlanningMaterials({
       bookDir: input.bookDir,

+ 14 - 1
packages/core/src/agents/rules-reader.ts

@@ -126,7 +126,20 @@ export async function readBookRules(bookDir: string): Promise<ParsedBookRules |
   const legacyRaw = await tryReadFile(join(bookDir, "story/book_rules.md"));
   if (!legacyRaw) return null;
   // Legacy file: body intentionally preserved (narrow narrative rules).
-  return parseBookRules(legacyRaw);
+  //
+  // Phase hotfix 1: parseBookRules now returns null if the file is a Phase 5
+  // compat shim (no YAML, only the architect-emitted pointer prose). In that
+  // case we surface a warning so callers don't silently fall back to default
+  // empty rules — the common new-book path where story_frame.md frontmatter
+  // is broken AND no legacy rules ever existed.
+  const parsed = parseBookRules(legacyRaw);
+  if (parsed === null) {
+    // eslint-disable-next-line no-console
+    console.warn(
+      `[rules-reader] book_rules.md at ${bookDir}/story/book_rules.md is a Phase 5 compat shim with no authoritative rules — returning null instead of silently zeroing out rules. Fix the YAML frontmatter on outline/story_frame.md.`,
+    );
+  }
+  return parsed;
 }
 
 export async function readBookLanguage(bookDir: string): Promise<"zh" | "en" | undefined> {

+ 32 - 3
packages/core/src/models/book-rules.ts

@@ -45,7 +45,26 @@ export interface ParsedBookRules {
   readonly body: string;
 }
 
-export function parseBookRules(raw: string): ParsedBookRules {
+/**
+ * Phase 5 cleanup #3: book_rules.md is now a compat pointer shim — the
+ * authoritative YAML frontmatter lives on outline/story_frame.md. Detect a
+ * shim by its architect-emitted heading + pointer line so we can avoid
+ * treating it as a legitimate (default-empty) rules source.
+ *
+ * Markers (must match buildBookRulesShim() in architect.ts):
+ *   - 本书规则(兼容指针——已废弃) / Book Rules (compat pointer — deprecated)
+ *   - 本文件仅为外部读取保留 / This file is kept for external readers only
+ */
+export function isBookRulesShim(raw: string): boolean {
+  return (
+    /本书规则(兼容指针——已废弃)/.test(raw)
+    || /Book Rules \(compat pointer — deprecated\)/.test(raw)
+    || /本文件仅为外部读取保留/.test(raw)
+    || /This file is kept for external readers only/.test(raw)
+  );
+}
+
+export function parseBookRules(raw: string): ParsedBookRules | null {
   // Strip markdown code block wrappers if present (LLM often wraps output in ```md ... ```)
   const stripped = raw.replace(/^```(?:md|markdown|yaml)?\s*\n/, "").replace(/\n```\s*$/, "");
 
@@ -58,11 +77,21 @@ export function parseBookRules(raw: string): ParsedBookRules {
       const body = fmMatch[2].trim();
       return { rules, body };
     } catch {
-      // YAML parse failed — fall through to default
+      // YAML parse failed — fall through to shim/default check.
     }
   }
 
-  // No valid frontmatter found — return default rules with the raw content as body
+  // Phase hotfix 1: refuse to silently zero out rules when reading a Phase 5
+  // compat shim. The shim has no real rules; pretending it parses as
+  // "default empty" wipes protagonist / prohibitions / genreLock for any
+  // caller that fell back to it after a broken story_frame frontmatter.
+  if (isBookRulesShim(stripped)) {
+    return null;
+  }
+
+  // No valid frontmatter found and not a shim — return default rules with
+  // the raw content as body. Keeps backward compat for legacy book_rules.md
+  // files that hold only narrow narrative guidance prose.
   const rules = BookRulesSchema.parse({});
   return { rules, body: stripped.trim() };
 }