Quellcode durchsuchen

fix(v13): fall back to legacy book_rules.md when story_frame frontmatter is broken [phase 5 hotfix 3]

parseBookRules returns defaults on YAML failure with no signal — so a
user hand-edit that broke the story_frame.md frontmatter would silently
zero out protagonist / prohibitions / genreLock even when a valid
legacy book_rules.md still existed on disk.

- Add tryParseBookRulesFrontmatter(raw, onError?): strict variant that
  returns null on missing frontmatter / invalid YAML / Zod failure
  instead of falling back to defaults. Surfaces the error via the
  optional onError callback.
- readBookRules() now uses the strict parser for story_frame.md. When
  it returns null (= frontmatter broken or missing), a warning is
  logged and the legacy book_rules.md path is consulted. Valid YAML
  still takes priority over legacy, preserving Phase 5 cleanup #3.
- Export tryParseBookRulesFrontmatter from the package entry.

parseBookRules's existing behavior is unchanged (still returns defaults
on failure) — the new function is additive, so downstream callers that
want best-effort parsing keep working.
Ma vor 6 Monaten
Ursprung
Commit
33a70ec3be

+ 12 - 4
packages/core/src/agents/rules-reader.ts

@@ -2,7 +2,7 @@ import { readFile, readdir } from "node:fs/promises";
 import { join, dirname } from "node:path";
 import { join, dirname } from "node:path";
 import { fileURLToPath } from "node:url";
 import { fileURLToPath } from "node:url";
 import { parseGenreProfile, type ParsedGenreProfile } from "../models/genre-profile.js";
 import { parseGenreProfile, type ParsedGenreProfile } from "../models/genre-profile.js";
-import { parseBookRules, type ParsedBookRules } from "../models/book-rules.js";
+import { parseBookRules, tryParseBookRulesFrontmatter, type ParsedBookRules } from "../models/book-rules.js";
 import { BookConfigSchema } from "../models/book.js";
 import { BookConfigSchema } from "../models/book.js";
 
 
 const BUILTIN_GENRES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../genres");
 const BUILTIN_GENRES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../genres");
@@ -109,9 +109,17 @@ export async function readBookRules(bookDir: string): Promise<ParsedBookRules |
     // outline prose and must NOT leak into ParsedBookRules.body.
     // outline prose and must NOT leak into ParsedBookRules.body.
     const frontmatterMatch = storyFrameRaw.match(/^\s*(---\s*\n[\s\S]*?\n---\s*)(?:\n|$)/);
     const frontmatterMatch = storyFrameRaw.match(/^\s*(---\s*\n[\s\S]*?\n---\s*)(?:\n|$)/);
     if (frontmatterMatch) {
     if (frontmatterMatch) {
-      // Pass only the frontmatter block (no trailing prose). parseBookRules
-      // will produce an empty body, which is exactly what we want.
-      return parseBookRules(frontmatterMatch[1]);
+      // Phase 5 hotfix 3: use the strict parser so a broken YAML block does
+      // NOT silently zero out protagonist / prohibitions / genreLock. If the
+      // frontmatter is malformed we log and fall through to legacy.
+      const parsed = tryParseBookRulesFrontmatter(frontmatterMatch[1], (err) => {
+        // eslint-disable-next-line no-console
+        console.warn(
+          `[rules-reader] story_frame.md frontmatter is malformed at ${bookDir}/story/outline/story_frame.md — falling back to legacy book_rules.md. Error: ${err instanceof Error ? err.message : String(err)}`,
+        );
+      });
+      if (parsed) return parsed;
+      // fall through to legacy fallback below
     }
     }
   }
   }
 
 

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

@@ -4,7 +4,7 @@ export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusS
 export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type AgentLLMOverride, type InputGovernanceMode, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema, InputGovernanceModeSchema } from "./models/project.js";
 export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type AgentLLMOverride, type InputGovernanceMode, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema, InputGovernanceModeSchema } from "./models/project.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
 export { type GenreProfile, type ParsedGenreProfile, GenreProfileSchema, parseGenreProfile } from "./models/genre-profile.js";
 export { type GenreProfile, type ParsedGenreProfile, GenreProfileSchema, parseGenreProfile } from "./models/genre-profile.js";
-export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules } from "./models/book-rules.js";
+export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules, tryParseBookRulesFrontmatter } from "./models/book-rules.js";
 export { type DetectionHistoryEntry, type DetectionStats } from "./models/detection.js";
 export { type DetectionHistoryEntry, type DetectionStats } from "./models/detection.js";
 export { type StyleProfile } from "./models/style-profile.js";
 export { type StyleProfile } from "./models/style-profile.js";
 export { type LengthCountingMode, type LengthNormalizeMode, type LengthSpec, type LengthTelemetry, type LengthWarning, LengthCountingModeSchema, LengthNormalizeModeSchema, LengthSpecSchema, LengthTelemetrySchema, LengthWarningSchema } from "./models/length-governance.js";
 export { type LengthCountingMode, type LengthNormalizeMode, type LengthSpec, type LengthTelemetry, type LengthWarning, LengthCountingModeSchema, LengthNormalizeModeSchema, LengthSpecSchema, LengthTelemetrySchema, LengthWarningSchema } from "./models/length-governance.js";

+ 29 - 0
packages/core/src/models/book-rules.ts

@@ -66,3 +66,32 @@ export function parseBookRules(raw: string): ParsedBookRules {
   const rules = BookRulesSchema.parse({});
   const rules = BookRulesSchema.parse({});
   return { rules, body: stripped.trim() };
   return { rules, body: stripped.trim() };
 }
 }
+
+/**
+ * Stricter variant of parseBookRules: returns null if the input has no valid
+ * YAML frontmatter OR if the frontmatter fails to parse / validate. Unlike
+ * parseBookRules, this never falls back to default rules — callers can use
+ * the null return to trigger their own fallback (e.g. legacy book_rules.md).
+ *
+ * Phase 5 hotfix 3: readBookRules() uses this to detect a broken YAML block
+ * on story_frame.md and fall back to legacy book_rules.md instead of
+ * silently clearing protagonist / prohibitions / genreLock.
+ */
+export function tryParseBookRulesFrontmatter(
+  raw: string,
+  onError?: (error: unknown) => void,
+): ParsedBookRules | null {
+  const stripped = raw.replace(/^```(?:md|markdown|yaml)?\s*\n/, "").replace(/\n```\s*$/, "");
+  const fmMatch = stripped.match(/---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
+  if (!fmMatch) return null;
+
+  try {
+    const frontmatter = yaml.load(fmMatch[1]) as Record<string, unknown>;
+    const rules = BookRulesSchema.parse(frontmatter);
+    const body = fmMatch[2].trim();
+    return { rules, body };
+  } catch (err) {
+    if (onError) onError(err);
+    return null;
+  }
+}