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

fix(v13): detect new vs legacy book layout before blocking shim edits [hotfix 1]

Add isNewLayoutBook(bookDir) that checks for outline/story_frame.md on
disk. Studio GET/PUT/list and write_truth_file now only treat
story_bible.md / book_rules.md as read-only shims when the book has the
new Phase 5 layout; pre-Phase-5 books can edit them normally.
Ma 6 месяцев назад
Родитель
Сommit
b4345c1524

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

@@ -110,6 +110,7 @@ export { getFanficDimensionConfig, FANFIC_DIMENSIONS, type FanficDimensionConfig
 export { buildFanficCanonSection, buildCharacterVoiceProfiles, buildFanficModeInstructions } from "./agents/fanfic-prompt-sections.js";
 
 // Utils
+export { isNewLayoutBook } from "./utils/outline-paths.js";
 export { fetchUrl, searchWeb } from "./utils/web-search.js";
 export { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "./utils/context-filter.js";
 export { extractPOVFromOutline, filterMatrixByPOV, filterHooksByPOV } from "./utils/pov-filter.js";

+ 19 - 5
packages/core/src/pipeline/agent.ts

@@ -565,11 +565,13 @@ export async function executeAgentTool(
 
       // Whitelist allowed truth files.
       //
-      // Phase 5 hotfix 1: book_rules.md and story_bible.md are REMOVED — they
-      // are pointer shims, not authoritative; writes to them are a silent
-      // no-op to the runtime (which reads outline/story_frame.md). The agent
-      // must instead edit outline/story_frame.md's YAML frontmatter + prose.
+      // Hotfix: story_bible.md and book_rules.md are back in the whitelist —
+      // they are authoritative for pre-Phase-5 books. For new-layout books
+      // (outline/story_frame.md exists) they're compat shims and writes are
+      // blocked below.
+      const LEGACY_SHIM_FILES = new Set(["story_bible.md", "book_rules.md"]);
       const ALLOWED_FLAT_FILES = [
+        "story_bible.md", "book_rules.md",
         "current_state.md", "particle_ledger.md", "pending_hooks.md",
         "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md",
         "character_matrix.md", "style_guide.md",
@@ -599,10 +601,22 @@ export async function executeAgentTool(
         ];
         return JSON.stringify({
           error:
-            `不允许修改文件 "${fileName}"。book_rules.md 和 story_bible.md 是兼容指针,请改写 outline/story_frame.md。允许的文件:${allowedExamples.join(", ")}`,
+            `不允许修改文件 "${fileName}"。允许的文件:${allowedExamples.join(", ")}`,
         });
       }
 
+      // For new-layout books, story_bible.md / book_rules.md are shims —
+      // block writes so the agent edits outline/story_frame.md instead.
+      if (LEGACY_SHIM_FILES.has(fileName)) {
+        const { isNewLayoutBook } = await import("../utils/outline-paths.js");
+        const bookDirForCheck = new (await import("../state/manager.js")).StateManager(config.projectRoot).bookDir(bookId);
+        if (await isNewLayoutBook(bookDirForCheck)) {
+          return JSON.stringify({
+            error: `"${fileName}" 是兼容指针(新布局书籍),请改写 outline/story_frame.md。`,
+          });
+        }
+      }
+
       // Path traversal guard — the whitelist already forbids `..`, but we
       // re-assert at the write site so this cannot regress.
       if (fileName.includes("..") || fileName.startsWith("/") || fileName.includes("\0")) {

+ 15 - 1
packages/core/src/utils/outline-paths.ts

@@ -13,9 +13,23 @@
  * legacy file content, or an empty default placeholder.
  */
 
-import { readFile, readdir } from "node:fs/promises";
+import { readFile, readdir, access } from "node:fs/promises";
 import { join } from "node:path";
 
+/**
+ * Detect whether a book uses the Phase 5 new layout (outline/story_frame.md
+ * exists on disk). If yes, story_bible.md / book_rules.md are compat shims.
+ * If no, those files ARE the authoritative source.
+ */
+export async function isNewLayoutBook(bookDir: string): Promise<boolean> {
+  try {
+    await access(join(bookDir, "story", "outline", "story_frame.md"));
+    return true;
+  } catch {
+    return false;
+  }
+}
+
 async function readOr(path: string, fallback: string): Promise<string> {
   try {
     return await readFile(path, "utf-8");

+ 20 - 9
packages/studio/src/api/server.ts

@@ -336,7 +336,10 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     // A legacy book may only have story_bible.md / book_rules.md on disk —
     // we still serve those for read-only display, but flag them so the UI
     // can warn users their edits won't reach the runtime.
-    const legacy = LEGACY_SHIM_FILES.has(file);
+    // Hotfix: only tag as legacy when the book actually HAS the new layout.
+    // Pre-Phase-5 books use story_bible/book_rules as the authoritative source.
+    const { isNewLayoutBook } = await import("@actalk/inkos-core");
+    const legacy = LEGACY_SHIM_FILES.has(file) && await isNewLayoutBook(bookDir);
 
     try {
       const content = await readFile(resolved, "utf-8");
@@ -521,11 +524,16 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       }
     }
 
+    // Hotfix: only tag shim files as legacy when the book has the new layout.
+    const { isNewLayoutBook } = await import("@actalk/inkos-core");
+    const newLayout = await isNewLayoutBook(bookDir);
+
     async function describe(relPath: string): Promise<{ readonly name: string; readonly size: number; readonly preview: string; readonly legacy?: true } | null> {
       try {
         const content = await readFile(join(storyDir, relPath), "utf-8");
+        const isShim = LEGACY_SHIM_FILES.has(relPath) && newLayout;
         const entry: { readonly name: string; readonly size: number; readonly preview: string; readonly legacy?: true } =
-          LEGACY_SHIM_FILES.has(relPath)
+          isShim
             ? { name: relPath, size: content.length, preview: content.slice(0, 200), legacy: true }
             : { name: relPath, size: content.length, preview: content.slice(0, 200) };
         return entry;
@@ -984,14 +992,17 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     if (!resolved) {
       return c.json({ error: "Invalid truth file" }, 400);
     }
-    // Legacy pointer shims are read-only: writing story_bible.md or
-    // book_rules.md does nothing at runtime (the pipeline reads outline/
-    // instead), so refuse to accept edits that would silently no-op.
+    // Legacy pointer shims are read-only in new-layout books: writing
+    // story_bible.md or book_rules.md does nothing at runtime (the pipeline
+    // reads outline/ instead). For pre-Phase-5 books these ARE authoritative.
     if (LEGACY_SHIM_FILES.has(file)) {
-      return c.json(
-        { error: "Legacy compat shim; edit outline/story_frame.md instead" },
-        400,
-      );
+      const { isNewLayoutBook } = await import("@actalk/inkos-core");
+      if (await isNewLayoutBook(bookDir)) {
+        return c.json(
+          { error: "Legacy compat shim; edit outline/story_frame.md instead" },
+          400,
+        );
+      }
     }
     const { content } = await c.req.json<{ content: string }>();
     const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");